From e2fc52f88cb8019fd240a2f9e3501b49ab104dc8 Mon Sep 17 00:00:00 2001
From: Sergey Fedorov <vital.had@gmail.com>
Date: Wed, 19 Aug 2026 14:37:21 +0800
Subject: [PATCH] Remove WebView

---
 data/info.febvre.Komikku.gresource.xml.in     |   1 -
 data/meson.build                              |   1 -
 data/ui/preferences.blp                       |   1 +
 data/ui/webview.blp                           |  18 -
 komikku/application.py                        |   2 -
 komikku/debug_info.py                         |   4 -
 komikku/servers/comichubfree/__init__.py      | 290 ------
 komikku/servers/comix/__init__.py             | 686 --------------
 komikku/servers/goldenmangas/__init__.py      | 204 -----
 komikku/servers/japscan/__init__.py           | 300 -------
 komikku/servers/kaliscan/__init__.py          | 112 ---
 komikku/servers/mangaball/__init__.py         | 431 ---------
 komikku/servers/mangademon/__init__.py        | 302 -------
 komikku/servers/mangafire/__init__.py         | 501 -----------
 komikku/servers/mangafreak/__init__.py        | 241 -----
 komikku/servers/mangahere/__init__.py         | 289 ------
 komikku/servers/mangakawaii/__init__.py       | 425 ---------
 komikku/servers/mangamana/__init__.py         | 296 ------
 komikku/servers/manganelo/__init__.py         | 246 -----
 komikku/servers/mangatube/__init__.py         | 368 --------
 komikku/servers/mangaworld/__init__.py        | 251 ------
 komikku/servers/manhwahentai/__init__.py      |  83 --
 komikku/servers/multi/heancms/__init__.py     |   7 -
 komikku/servers/multi/keyoapp/__init__.py     |   6 -
 komikku/servers/multi/madara/__init__.py      |   8 -
 komikku/servers/multi/madtheme/__init__.py    |   7 -
 .../servers/multi/manga_stream/__init__.py    |   7 -
 komikku/servers/multi/paprika/__init__.py     |   7 -
 komikku/servers/multi/peachscan/__init__.py   |   7 -
 komikku/servers/multi/wpcomics/__init__.py    |   7 -
 komikku/servers/nhentai/__init__.py           | 202 -----
 komikku/servers/phenixscans/__init__.py       | 204 -----
 komikku/servers/raijinscan/__init__.py        | 304 -------
 komikku/servers/rawmanga/__init__.py          | 172 ----
 komikku/servers/readcomiconline/__init__.py   | 304 -------
 komikku/servers/readcomicsonline/__init__.py  | 257 ------
 komikku/servers/remanga/__init__.py           | 266 ------
 komikku/servers/scanmanga/__init__.py         | 286 ------
 komikku/servers/teamx/__init__.py             | 232 -----
 komikku/trackers/anilist/__init__.py          | 362 --------
 komikku/trackers/myanimelist/__init__.py      | 283 ------
 komikku/webview.py                            | 845 ------------------
 42 files changed, 1 insertion(+), 8824 deletions(-)
 delete mode 100644 data/ui/webview.blp
 delete mode 100644 komikku/servers/comichubfree/__init__.py
 delete mode 100644 komikku/servers/comix/__init__.py
 delete mode 100644 komikku/servers/goldenmangas/__init__.py
 delete mode 100644 komikku/servers/japscan/__init__.py
 delete mode 100644 komikku/servers/kaliscan/__init__.py
 delete mode 100644 komikku/servers/mangaball/__init__.py
 delete mode 100644 komikku/servers/mangademon/__init__.py
 delete mode 100644 komikku/servers/mangafire/__init__.py
 delete mode 100644 komikku/servers/mangafreak/__init__.py
 delete mode 100644 komikku/servers/mangahere/__init__.py
 delete mode 100644 komikku/servers/mangakawaii/__init__.py
 delete mode 100644 komikku/servers/mangamana/__init__.py
 delete mode 100644 komikku/servers/manganelo/__init__.py
 delete mode 100644 komikku/servers/mangatube/__init__.py
 delete mode 100644 komikku/servers/mangaworld/__init__.py
 delete mode 100644 komikku/servers/manhwahentai/__init__.py
 delete mode 100644 komikku/servers/nhentai/__init__.py
 delete mode 100644 komikku/servers/phenixscans/__init__.py
 delete mode 100644 komikku/servers/raijinscan/__init__.py
 delete mode 100644 komikku/servers/rawmanga/__init__.py
 delete mode 100644 komikku/servers/readcomiconline/__init__.py
 delete mode 100644 komikku/servers/readcomicsonline/__init__.py
 delete mode 100644 komikku/servers/remanga/__init__.py
 delete mode 100644 komikku/servers/scanmanga/__init__.py
 delete mode 100644 komikku/servers/teamx/__init__.py
 delete mode 100644 komikku/trackers/anilist/__init__.py
 delete mode 100644 komikku/trackers/myanimelist/__init__.py
 delete mode 100644 komikku/webview.py

diff --git a/data/info.febvre.Komikku.gresource.xml.in b/data/info.febvre.Komikku.gresource.xml.in
index 5908c536..9f0546b0 100644
--- a/data/info.febvre.Komikku.gresource.xml.in
+++ b/data/info.febvre.Komikku.gresource.xml.in
@@ -25,7 +25,6 @@
         <file compressed="true" preprocess="xml-stripblanks">ui/reader_settings.ui</file>
         <file compressed="true" preprocess="xml-stripblanks">ui/shortcuts.ui</file>
         <file compressed="true" preprocess="xml-stripblanks">ui/support.ui</file>
-        <file compressed="true" preprocess="xml-stripblanks">ui/webview.ui</file>
 
         <!-- Menus -->
         <file compressed="true" preprocess="xml-stripblanks">ui/menu/main.xml</file>
diff --git a/data/meson.build b/data/meson.build
index 269b2dfc..fe3025bd 100644
--- a/data/meson.build
+++ b/data/meson.build
@@ -141,7 +141,6 @@ blueprints = custom_target('blueprints',
         'ui/reader_settings.blp',
         'ui/shortcuts.blp',
         'ui/support.blp',
-        'ui/webview.blp',
     ),
     output: '.',
     command: [find_program('blueprint-compiler'), 'batch-compile', '--minify', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'],
diff --git a/data/ui/preferences.blp b/data/ui/preferences.blp
index cdf8c0d1..e93af79a 100644
--- a/data/ui/preferences.blp
+++ b/data/ui/preferences.blp
@@ -449,6 +449,7 @@ template $PreferencesDialog : Adw.PreferencesDialog {
     }
 
     Adw.PreferencesGroup {
+visible: false;
       title: _("WebView");
 
       Adw.ActionRow clear_webview_data_actionrow {
diff --git a/data/ui/webview.blp b/data/ui/webview.blp
deleted file mode 100644
index 1c4e2372..00000000
--- a/data/ui/webview.blp
+++ /dev/null
@@ -1,18 +0,0 @@
-// SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-//
-// SPDX-License-Identifier: GPL-3.0-or-later
-
-using Gtk 4.0;
-using Adw 1;
-
-template $WebviewPage : Adw.NavigationPage {
-  tag: "webview";
-  title: _("WebView");
-  child: Adw.ToolbarView toolbarview {
-    [top]
-    Adw.HeaderBar {
-      title-widget: Adw.WindowTitle title {
-      };
-    }
-  };
-}
diff --git a/komikku/application.py b/komikku/application.py
index 9397ecf9..a2dd9a5a 100644
--- a/komikku/application.py
+++ b/komikku/application.py
@@ -47,7 +47,6 @@ from komikku.support import SupportPage
 from komikku.trackers import Trackers
 from komikku.updater import Updater
 from komikku.utils import random_palette
-from komikku.webview import WebviewPage
 
 BANNER = """
 ██╗  ██╗ ██████╗ ███╗   ███╗██╗██╗  ██╗██╗  ██╗██╗   ██╗
@@ -310,7 +309,6 @@ class ApplicationWindow(Adw.ApplicationWindow):
         self.explorer = Explorer(self)
         self.history = HistoryPage(self)
         self.support = SupportPage(self)
-        self.webview = WebviewPage(self)
 
         # Init dialogs
         self.preferences = PreferencesDialog(self)
diff --git a/komikku/debug_info.py b/komikku/debug_info.py
index 69adfc14..1745c31d 100644
--- a/komikku/debug_info.py
+++ b/komikku/debug_info.py
@@ -12,14 +12,12 @@ gi.require_version('Adw', '1')
 gi.require_version('GdkPixbuf', '2.0')
 gi.require_version('Gtk', '4.0')
 gi.require_version('Soup', '3.0')
-gi.require_version('WebKit', '6.0')
 
 from gi.repository import Adw
 from gi.repository import GdkPixbuf
 from gi.repository import GLib
 from gi.repository import Gtk
 from gi.repository import Soup
-from gi.repository import WebKit
 
 from komikku.models.database import VERSION as DB_VERSION
 from komikku.utils import check_cmdline_tool
@@ -115,14 +113,12 @@ class DebugInfo:
         info += f'- GdkPixbuf: {GdkPixbuf.PIXBUF_VERSION}\n'
         info += f'- GTK: {Gtk.MAJOR_VERSION}.{Gtk.MINOR_VERSION}.{Gtk.MICRO_VERSION}\n'
         info += f'- Adwaita: {Adw.VERSION_S}\n'
-        info += f'- WebKitGTK: {WebKit.MAJOR_VERSION}.{WebKit.MINOR_VERSION}.{WebKit.MICRO_VERSION}\n'
         info += f'- Soup: {Soup.MAJOR_VERSION}.{Soup.MINOR_VERSION}.{Soup.MICRO_VERSION}\n'
         info += '\n'
 
         info += 'Running against:\n'
         info += f'- GTK: {Gtk.get_major_version()}.{Gtk.get_minor_version()}.{Gtk.get_micro_version()}\n'
         info += f'- Adwaita: {Adw.get_major_version()}.{Adw.get_minor_version()}.{Adw.get_micro_version()}\n'
-        info += f'- WebKitGTK: {WebKit.get_major_version()}.{WebKit.get_minor_version()}.{WebKit.get_micro_version()}\n'
         info += f'- Soup: {Soup.get_major_version()}.{Soup.get_minor_version()}.{Soup.get_micro_version()}\n'
         info += f'- Magic: {magic.version()}\n'
         info += '\n'
diff --git a/komikku/servers/comichubfree/__init__.py b/komikku/servers/comichubfree/__init__.py
deleted file mode 100644
index 7b4012de..00000000
--- a/komikku/servers/comichubfree/__init__.py
+++ /dev/null
@@ -1,290 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import time
-
-from bs4 import BeautifulSoup
-
-from komikku.consts import DOWNLOAD_MAX_DELAY
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import get_response_elapsed
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-# Similar to dead server comicextra
-
-
-class Comichubfree(Server):
-    id = 'comichubfree'
-    name = 'ComicHubFree'
-    lang = 'en'
-
-    has_cf = True
-
-    base_url = 'https://comichubfree.com'
-    logo_url = base_url + '/favicon.png'
-    search_url = base_url + '/search-comic'
-    latest_updates_url = base_url + '/comic-updates'
-    most_populars_url = base_url + '/popular-comic'
-    manga_url = base_url + '/comic/{0}'
-    chapter_url = base_url + '/{0}/{1}/all'
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns comic data by scraping manga HTML page content
-
-        Initial data should contain at least comic's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],  # not available
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        ))
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data['name'] = soup.select_one('.breadcrumb li:last-child span[itemprop="name"]').text.strip()
-        data['cover'] = soup.select_one('.movie-image img').get('data-src')
-
-        for element in soup.select('.movie-dl dd.status'):
-            status = element.text.strip().lower()
-            if status == 'completed':
-                data['status'] = 'complete'
-            elif status == 'ongoing':
-                data['status'] = 'ongoing'
-
-        if element := soup.select_one('.movie-meta-info dt:-soup-contains("Author")'):
-            data['authors'].append(element.find_next_siblings()[0].text.strip())
-
-        if element := soup.select_one('.movie-meta-info dt:-soup-contains("Genres")'):
-            for a_element in element.find_next_siblings()[0].select('a'):
-                data['genres'].append(a_element.text.strip())
-
-        data['synopsis'] = soup.select_one('#film-content').text.strip()
-
-        # Chapters (Issues)
-        data['chapters'] = self.get_manga_chapters_data(data['slug'], soup=soup)
-
-        return data
-
-    def get_manga_chapters_data(self, slug, page=None, soup=None, chapters=None):
-        if chapters is None:
-            chapters = []
-
-        if soup is None:
-            r = self.session_get(self.manga_url.format(slug), params=dict(page=page))
-            if r.status_code != 200:
-                return None
-
-            mime_type = get_buffer_mime_type(r.content)
-            if mime_type != 'text/html':
-                return None
-
-            rtime = get_response_elapsed(r)
-            soup = BeautifulSoup(r.text, 'lxml')
-        else:
-            rtime = None
-
-        for tr_element in soup.select('#list tr'):
-            a_element = tr_element.select_one('a')
-            td_elements = tr_element.select('td')
-
-            chapter_slug = a_element.get('href').split('/')[-1]
-            num = chapter_slug.replace('issue-', '')
-
-            chapters.append(dict(
-                slug=chapter_slug,
-                title=a_element.text.strip(),
-                num=num if is_number(num) else None,
-                date=convert_date_string(td_elements[1].text.strip(), '%d-%b-%Y', languages=[self.lang]),
-            ))
-
-        if next_element := soup.select_one('ul.pagination > li > a[rel="next"]'):
-            if rtime:
-                time.sleep(min(rtime * 4, DOWNLOAD_MAX_DELAY))
-
-            next_page = next_element.get('href').split('=')[-1]
-            self.get_manga_chapters_data(slug, page=next_page, chapters=chapters)
-
-        return list(reversed(chapters))
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns comic chapter data
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for index, img_element in enumerate(soup.select('.chapter-container .page-chapter img')):
-            data['pages'].append(dict(
-                image=img_element.get('data-src').strip(),
-                slug=None,
-                index=index + 1,
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=f"{page['index']}.{mime_type.split('/')[1]}",
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns comic absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    @CompleteChallenge()
-    def get_latest_updates(self):
-        """
-        Returns daily updates
-        """
-        r = self.session.get(
-            self.latest_updates_url,
-            headers={
-                'Referer': f'{self.base_url}/',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.select('.hl-box'):
-            a_element = element.select_one('a')
-            last_a_element = element.select_one('.hlb-list a')
-
-            results.append(dict(
-                name=a_element.text.strip(),
-                slug=a_element.get('href').split('/')[-1],
-                last_chapter=last_a_element.text.strip() if last_a_element else None,
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def get_most_populars(self):
-        """
-        Returns popular comics
-        """
-        r = self.session.get(
-            self.most_populars_url,
-            headers={
-                'Referer': f'{self.base_url}/',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.select('.cartoon-box'):
-            a_element = element.select_one('.mb-right h3 a')
-            img_element = element.select_one('.image img')
-            nb_chapters_element = element.select_one('.mb-right .detail')
-
-            results.append(dict(
-                name=a_element.text.strip(),
-                slug=a_element.get('href').split('/')[-1],
-                cover=img_element.get('data-src'),
-                nb_chapters=nb_chapters_element.text.split()[0],
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def search(self, term):
-        r = self.session.get(
-            self.search_url,
-            params=dict(
-                key=term,
-            ),
-            headers={
-                'Referer': f'{self.base_url}/',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.select('.cartoon-box'):
-            a_element = element.select_one('.mb-right h3 a')
-            img_element = element.select_one('.image img')
-            nb_chapters_element = element.select_one('.mb-right .detail')
-
-            results.append(dict(
-                name=a_element.text.strip(),
-                slug=a_element.get('href').split('/')[-1],
-                cover=img_element.get('data-src'),
-                nb_chapters=nb_chapters_element.text.split()[0],
-            ))
-
-        return results
diff --git a/komikku/servers/comix/__init__.py b/komikku/servers/comix/__init__.py
deleted file mode 100644
index 122aacc2..00000000
--- a/komikku/servers/comix/__init__.py
+++ /dev/null
@@ -1,686 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import base64
-from gettext import gettext as _
-import json
-import time
-import urllib.parse
-
-from bs4 import BeautifulSoup
-
-from komikku.consts import DOWNLOAD_MAX_DELAY
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import get_response_elapsed
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-SEARCH_RESULTS_PAGES = 4
-
-KEYS = {
-    0: '13YDu67uDgFczo3DnuTIURqas4lfMEPADY6Jaeqky+w=',   # 0  RC4 key  round 1
-    1: 'yEy7wBfBc+gsYPiQL/4Dfd0pIBZFzMwrtlRQGwMXy3Q=',   # 1  mut_key  round 1
-    2: 'yrP+EVA1Dw==',                                   # 2  pref_key round 1
-    3: 'vZ23RT7pbSlxwiygkHd1dhToIku8SNHPC6V36L4cnwM=',   # 3  RC4 key  round 2
-    4: 'QX0sLahOByWLcWGnv6l98vQudWqdRI3DOXBdit9bxCE=',   # 4  mut_key  round 2
-    5: 'WJwgqCmf',                                       # 5  pref_key round 2
-    6: 'BkWI8feqSlDZKMq6awfzWlUypl88nz65KVRmpH0RWIc=',   # 6  RC4 key  round 3
-    7: 'v7EIpiQQjd2BGuJzMbBA0qPWDSS+wTJRQ7uGzZ6rJKs=',   # 7  mut_key  round 3
-    8: '1SUReYlCRA==',                                   # 8  pref_key round 3
-    9: 'RougjiFHkSKs20DZ6BWXiWwQUGZXtseZIyQWKz5eG34=',   # 9  RC4 key  round 4
-    10: 'LL97cwoDoG5cw8QmhI+KSWzfW+8VehIh+inTxnVJ2ps=',  # 10 mut_key  round 4
-    11: '52iDqjzlqe8=',                                  # 11 pref_key round 4
-    12: 'U9LRYFL2zXU4TtALIYDj+lCATRk/EJtH7/y7qYYNlh8=',  # 12 RC4 key  round 5
-    13: 'e/GtffFDTvnw7LBRixAD+iGixjqTq9kIZ1m0Hj+s6fY=',  # 13 mut_key  round 5
-    14: 'xb2XwHNB',                                      # 14 pref_key round 5
-}
-
-
-def generate_hash(path, body_size=0, time=1):
-    """
-    :param path: API path, e.g. '/manga/some-hash/chapters'
-    :param body_size: encodeURIComponent(body) length for POST, or 0 for GET
-    :param time: 1 for GET manga requests, `System.currentTimeMillis()` for POST
-    """
-
-    def get_key_bytes(index):
-        b64 = KEYS.get(index)
-        if not b64:
-            return []
-
-        key = []
-        for c in base64.b64decode(b64):
-            key.append(int(c) & 0xFF)
-
-        return key
-
-    def get_mut_key(mk, idx):
-        if mk and (idx % 32) < len(mk):
-            return mk[idx % 32]
-        return 0
-
-    def mut_s(e):
-        return (e + 143) % 256
-
-    def mut_l(e):
-        return ((e >> 1) | (e << 7)) & 255
-
-    def mut_c(e):
-        return (e + 115) % 256
-
-    def mut_m(e):
-        return e ^ 177
-
-    def mut_f(e):
-        return (e - 188 + 256) % 256
-
-    def mut_g(e):
-        return ((e << 2) | (e >> 6)) & 255
-
-    def mut_h(e):
-        return (e - 42 + 256) % 256
-
-    def mut_dollar(e):
-        return ((e << 4) | (e >> 4)) & 255
-
-    def mut_b(e):
-        return (e - 12 + 256) % 256
-
-    def mut_underscore(e):
-        return (e - 20 + 256) % 256
-
-    def mut_y(e):
-        return ((e >> 1) | (e << 7)) & 255
-
-    def mut_k(e):
-        return (e - 241 + 256) % 256
-
-    def RC4(key, data):
-        if not key:
-            return data
-        s = list(range(256))
-        j = 0
-        for i in range(256):
-            j = (j + s[i] + key[i % len(key)]) % 256
-            temp = s[i]
-            s[i] = s[j]
-            s[j] = temp
-
-        i = 0
-        j = 0
-        out = [0] * len(data)
-        for k in range(len(data)):
-            i = (i + 1) % 256
-            j = (j + s[i]) % 256
-            temp = s[i]
-            s[i] = s[j]
-            s[j] = temp
-            out[k] = data[k] ^ s[(s[i] + s[j]) % 256]
-
-        return out
-
-    def round1(data):
-        enc = RC4(get_key_bytes(0), data)
-        mut_key = get_key_bytes(1)
-        pref_key = get_key_bytes(2)
-        out = []
-
-        for i, c in enumerate(enc):
-            if i < 7 and i < len(pref_key):
-                out.append(pref_key[i])
-
-            v = c ^ get_mut_key(mut_key, i)
-            if i % 10 in (0, 9):
-                v = mut_c(v)
-            elif i % 10 == 1:
-                v = mut_b(v)
-            elif i % 10 == 2:
-                v = mut_y(v)
-            elif i % 10 == 3:
-                v = mut_dollar(v)
-            elif i % 10 in (4, 6):
-                v = mut_h(v)
-            elif i % 10 == 5:
-                v = mut_s(v)
-            elif i % 10 == 7:
-                v = mut_k(v)
-            elif i % 10 == 8:
-                v = mut_l(v)
-
-            out.append(v & 255)
-
-        return out
-
-    def round2(data):
-        enc = RC4(get_key_bytes(3), data)
-        mut_key = get_key_bytes(4)
-        pref_key = get_key_bytes(5)
-        out = []
-
-        for i, c in enumerate(enc):
-            if i < 6 and i < len(pref_key):
-                out.append(pref_key[i])
-
-            v = c ^ get_mut_key(mut_key, i)
-            if i % 10 in (0, 8):
-                v = mut_c(v)
-            elif i % 10 == 1:
-                v = mut_b(v)
-            elif i % 10 in (2, 6):
-                v = mut_dollar(v)
-            elif i % 10 == 3:
-                v = mut_h(v)
-            elif i % 10 in (4, 9):
-                v = mut_s(v)
-            elif i % 10 == 5:
-                v = mut_k(v)
-            elif i % 10 == 7:
-                v = mut_underscore(v)
-
-            out.append(v & 255)
-
-        return out
-
-    def round3(data):
-        enc = RC4(get_key_bytes(6), data)
-        mut_key = get_key_bytes(7)
-        pref_key = get_key_bytes(8)
-        out = []
-
-        for i, c in enumerate(enc):
-            if i < 7 and i < len(pref_key):
-                out.append(pref_key[i])
-
-            v = c ^ get_mut_key(mut_key, i)
-            if i % 10 == 0:
-                v = mut_c(v)
-            elif i % 10 == 1:
-                v = mut_f(v)
-            elif i % 10 in (2, 8):
-                v = mut_s(v)
-            elif i % 10 == 3:
-                v = mut_g(v)
-            elif i % 10 == 4:
-                v = mut_y(v)
-            elif i % 10 == 5:
-                v = mut_m(v)
-            elif i % 10 == 6:
-                v = mut_dollar(v)
-            elif i % 10 == 7:
-                v = mut_k(v)
-            elif i % 10 == 9:
-                v = mut_b(v)
-
-            out.append(v & 255)
-
-        return out
-
-    def round4(data):
-        enc = RC4(get_key_bytes(9), data)
-        mut_key = get_key_bytes(10)
-        pref_key = get_key_bytes(11)
-        out = []
-
-        for i, c in enumerate(enc):
-            if i < 8 and i < len(pref_key):
-                out.append(pref_key[i])
-
-            v = c ^ get_mut_key(mut_key, i)
-            if i % 10 == 0:
-                v = mut_b(v)
-            elif i % 10 in (1, 9):
-                v = mut_m(v)
-            elif i % 10 in (2, 7):
-                v = mut_l(v)
-            elif i % 10 in (3, 5):
-                v = mut_s(v)
-            elif i % 10 in (4, 6):
-                v = mut_underscore(v)
-            elif i % 10 == 8:
-                v = mut_y(v)
-
-            out.append(v & 255)
-
-        return out
-
-    def round5(data):
-        enc = RC4(get_key_bytes(12), data)
-        mut_key = get_key_bytes(13)
-        pref_key = get_key_bytes(14)
-        out = []
-
-        for i, c in enumerate(enc):
-            if i < 6 and i < len(pref_key):
-                out.append(pref_key[i])
-
-            v = c ^ get_mut_key(mut_key, i)
-            if i % 10 == 0:
-                v = mut_underscore(v)
-            elif i % 10 in (1, 7):
-                v = mut_s(v)
-            elif i % 10 == 2:
-                v = mut_c(v)
-            elif i % 10 in (3, 5):
-                v = mut_m(v)
-            elif i % 10 == 4:
-                v = mut_b(v)
-            elif i % 10 == 6:
-                v = mut_f(v)
-            elif i % 10 == 8:
-                v = mut_dollar(v)
-            elif i % 10 == 9:
-                v = mut_g(v)
-
-            out.append(v & 255)
-
-        return out
-
-    encoded = urllib.parse.quote_plus(path).replace('+', '%20').replace('*', '%2A').replace('%7E', '~')
-
-    initial_bytes = []
-    for i in encoded.encode():
-        initial_bytes.append(int(i) & 0xFF)
-
-    r1 = round1(initial_bytes)
-    r2 = round2(r1)
-    r3 = round3(r2)
-    r4 = round4(r3)
-    r5 = round5(r4)
-
-    final_bytes = []
-    for c in r5:
-        final_bytes.append(c.to_bytes(1))
-
-    return base64.b64encode(b''.join(final_bytes), altchars=b'-_').replace(b'=', b'')
-
-
-class Comix(Server):
-    id = 'comix'
-    name = 'Comix'
-    lang = 'en'
-    is_nsfw = True
-
-    has_cf = True
-
-    base_url = 'https://comix.to'
-    logo_url = base_url + '/icon.png?icon.530a1f27.png'
-    manga_url = base_url + '/title/{0}'
-    chapter_url = base_url + '/title/{0}/{1}'
-    api_url = 'https://comix.to/api/v1'
-    api_search_url = api_url + '/manga'
-    api_chapters_url = api_url + '/manga/{0}/chapters'
-    api_chapter_url = api_url + '/chapters/{0}'
-
-    filters = [
-        {
-            'key': 'statuses',
-            'type': 'select',
-            'name': _('Status'),
-            'description': _('Filter by Statuses'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'releasing', 'name': _('Ongoing'), 'default': False},
-                {'key': 'finished', 'name': _('Completed'), 'default': False},
-                {'key': 'on_hiatus', 'name': _('Hiatus'), 'default': False},
-                {'key': 'discontinued', 'name': _('Canceled'), 'default': False},
-            ]
-        },
-        {
-            'key': 'types',
-            'type': 'select',
-            'name': _('Type'),
-            'description': _('Filter by Types'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'manga', 'name': _('Manga'), 'default': False},
-                {'key': 'manhwa', 'name': _('Manhwa'), 'default': False},
-                {'key': 'manhua', 'name': _('Manhua'), 'default': False},
-                {'key': 'other', 'name': _('Other'), 'default': False},
-            ],
-        },
-        {
-            'key': 'demographics',
-            'type': 'select',
-            'name': _('Publication Demographic'),
-            'description': _('Filter by Publication Demographics'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 3, 'name': _('Josei'), 'default': False},
-                {'key': 4, 'name': _('Seinen'), 'default': False},
-                {'key': 1, 'name': _('Shoujo'), 'default': False},
-                {'key': 2, 'name': _('Shounen'), 'default': False},
-            ]
-        },
-    ]
-
-    long_strip_genres = ['Manhua', 'Manhwa']
-
-    params = [
-        {
-            'key': 'hide_nsfw',
-            'type': 'checkbox',
-            'name': _('Hide NSFW Content'),
-            'description': _('Hide NSFW content from popular, latest, and search lists'),
-            'default': True,
-        },
-    ]
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content and API for chapters
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        if script_element := soup.select_one('script#initial-data'):
-            manga_data = json.loads(script_element.string)
-            hid = manga_data['manga']['hid']
-            detail = manga_data['queries'][f'["manga","detail","{hid}"]']  # noqa
-            groups = manga_data['queries'].get(f'["manga","groups","{hid}"]', [])  # noqa
-        else:
-            return None
-
-        data = initial_data.copy()
-        data.update(dict(
-            name=detail['title'],
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=detail.get('synopsis'),
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        ))
-
-        if posters := detail.get('poster'):
-            data['cover'] = posters['medium']
-
-        # Details
-        if status := detail.get('status'):
-            if status == 'finished':
-                data['status'] = 'complete'
-            elif status == 'releasing':
-                data['status'] = 'ongoing'
-            elif status == 'on_hiatus':
-                data['status'] = 'hiatus'
-            elif status == 'discontinued':
-                data['status'] = 'suspended'
-
-        for author in detail.get('authors', []):
-            data['authors'].append(author['title'].strip())
-        for artist in detail.get('artists', []):
-            title = artist['title'].strip()
-            if title not in data['authors']:
-                data['authors'].append(title)
-
-        for genre in detail.get('genres', []):
-            data['genres'].append(genre['title'].strip())
-        for demographic in detail.get('demographics', []):
-            data['genres'].append(demographic['title'].strip())
-        if type_ := detail.get('type'):
-            data['genres'].append(type_.capitalize())
-        if year := detail.get('year'):
-            data['genres'].append(str(year))
-
-        for group in groups:
-            name = group['name'].strip()
-            if name.lower() in ('official?', 'unknown group'):
-                continue
-            data['scanlators'].append(name)
-
-        # Chapters
-        data['chapters'] = self.get_manga_chapters_data(data['slug'])
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data using API
-
-        Pages URLs are available in a <script> element
-        """
-        hash_id = chapter_slug.split('-')[0]
-        path = f'/chapters/{hash_id}'
-        hash_token = generate_hash(path, 0, 1)
-
-        r = self.session_get(
-            self.api_chapter_url.format(hash_id),
-            params={
-                '_': hash_token,
-            },
-            headers={
-                'Content-Type': 'application/json',
-                'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        resp_data = r.json()
-        if resp_data['status'] != 'ok':
-            return None
-
-        data = dict(
-            pages=[],
-        )
-        for page in resp_data['result']['pages']:
-            data['pages'].append(dict(
-                slug=None,
-                image=page['url'],
-            ))
-
-        return data
-
-    def get_manga_chapters_data(self, slug):
-        """
-        Returns manga chapters data using API
-        """
-        chapters = []
-        hash_id = slug.split('-')[0]
-        path = f'/manga/{hash_id}/chapters'
-        hash_token = generate_hash(path, 0, 1)
-
-        def get_page(page):
-            r = self.session_get(
-                self.api_chapters_url.format(hash_id),
-                params={
-                    'order[number]': 'desc',
-                    'limit': 100,
-                    'page': page,
-                    '_': hash_token,
-                },
-                headers={
-                    'Content-Type': 'application/json',
-                    'Referer': self.manga_url.format(slug),
-                    'X-Requested-With': 'XMLHttpRequest',
-                }
-            )
-            if r.status_code != 200:
-                return None, False, 0
-
-            resp_data = r.json()
-            if resp_data['status'] != 'ok':
-                return None, False, 0
-
-            more = page < resp_data['result']['meta']['lastPage']
-
-            return r.json()['result']['items'], more, get_response_elapsed(r)
-
-        chapters = []
-        delay = None
-        more = True
-        page = 1
-        while more:
-            if delay:
-                time.sleep(delay)
-
-            items, more, rtime = get_page(page)
-            if not items:
-                return []
-            for item in items:
-                title = f'Ch. {item["number"]}'
-                if item['volume']:
-                    title = f'{title} Vol {item["volume"]}'
-                if item['name']:
-                    title = f'{title} {item["name"]}'
-                if item['group']:
-                    scanlators = [item['group']['name']]
-                elif item['isOfficial']:
-                    scanlators = ['Official']
-                else:
-                    scanlators = []
-
-                date = item['createdAtFormatted']
-                # dateaprser doesn't support 'mos'
-                date = date.replace('mos', 'm')
-
-                chapters.append(dict(
-                    slug=f'{item["id"]}-chapter-{item["number"]}',
-                    title=title,
-                    scanlators=scanlators,
-                    num=item['number'] if is_number(item['number']) else None,
-                    num_volume=item['volume'] if is_number(item['volume']) else None,
-                    date=convert_date_string(date),
-                ))
-
-            delay = min(rtime * 4, DOWNLOAD_MAX_DELAY) if rtime else None
-            page += 1
-
-        return list(reversed(chapters))
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('/')[-1],
-        )
-
-    def get_manga_list(self, term=None, statuses=None, types=None, demographics=None, orderby=None):
-        def get_page(page):
-            params = {
-                'limit': 28,
-                'page': page,
-            }
-
-            if term:
-                params.update({
-                    'keyword': term,
-                    'order[relevance]': 'desc',
-                })
-            elif orderby == 'latest':
-                params.update({
-                    'order[chapter_updated_at]': 'desc',
-                })
-            elif orderby == 'popular':
-                params.update({
-                    'order[views_30d]': 'desc',
-                })
-
-            if statuses:
-                params['statuses[]'] = statuses
-            if types:
-                params['types[]'] = types
-            if demographics:
-                params['demographics[]'] = demographics
-
-            if self.get_param('hide_nsfw'):
-                params['genres[]'] = [-87264, -87266, -87268, -87265]
-
-            r = self.session_get(
-                self.api_search_url,
-                params=params,
-                headers={
-                    'Referer': f'{self.base_url}/browser',
-                }
-            )
-            if r.status_code != 200:
-                return [], False, None
-
-            resp_data = r.json()
-            if resp_data['status'] != 'ok':
-                return [], False, None
-
-            more = page < resp_data['result']['meta']['lastPage'] and page < SEARCH_RESULTS_PAGES
-
-            return resp_data['result']['items'], more, get_response_elapsed(r)
-
-        results = []
-        delay = None
-        more = True
-        page = 1
-        while more:
-            if delay:
-                time.sleep(delay)
-
-            items, more, rtime = get_page(page)
-            for item in items:
-                results.append(dict(
-                    slug=item['url'].split('/')[-1],
-                    name=item['title'],
-                    cover=item['poster']['medium'],
-                    last_chapter=item['latestChapter'],
-                    nb_chapters=item['finalChapter'],
-                ))
-
-            delay = min(rtime * 4, DOWNLOAD_MAX_DELAY) if rtime else None
-            page += 1
-
-        return results
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    @CompleteChallenge()
-    def get_latest_updates(self, statuses=None, types=None, demographics=None):
-        """
-        Returns latest updated mangas
-        """
-        return self.get_manga_list(statuses=statuses, types=types, demographics=demographics, orderby='latest')
-
-    @CompleteChallenge()
-    def get_most_populars(self, statuses=None, types=None, demographics=None):
-        """
-        Returns most popular mangas
-        """
-        return self.get_manga_list(statuses=statuses, types=types, demographics=demographics, orderby='popular')
-
-    @CompleteChallenge()
-    def search(self, term, statuses=None, types=None, demographics=None):
-        return self.get_manga_list(term=term, statuses=statuses, types=types, demographics=demographics)
diff --git a/komikku/servers/goldenmangas/__init__.py b/komikku/servers/goldenmangas/__init__.py
deleted file mode 100644
index 900b803e..00000000
--- a/komikku/servers/goldenmangas/__init__.py
+++ /dev/null
@@ -1,204 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from bs4 import BeautifulSoup
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
-
-
-class Goldenmangas(Server):
-    id = 'goldenmangas'
-    name = 'Golden Mangás'
-    lang = 'pt_BR'
-    is_nsfw = True
-    status = 'disabled'
-
-    has_cf = True
-
-    base_url = 'https://goldenmanga.top'
-    search_url = base_url + '/mangas'
-    manga_url = base_url + '/mangas/{0}'
-    chapter_url = base_url + '/mangas/{0}/{1}'
-    image_url = base_url + '/mm-admin/uploads/mangas/{0}/{1}/{2}'
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        ))
-
-        info_element = soup.select_one('div.container.manga > div.row')
-
-        # Name & cover
-        data['name'] = info_element.select_one('div.col-sm-8 > div.row > div.col-sm-8 > h2:nth-child(1)').text.strip()
-        data['cover'] = '{0}{1}'.format(
-            self.base_url,
-            info_element.select_one('div.col-sm-8 > div.row > div.col-sm-4.text-right > img').get('src')
-        )
-
-        # Details
-        for h5_element in info_element.select('div.col-sm-8 > div.row > div.col-sm-8 > h5'):
-            label = h5_element.strong.text.strip().lower()
-
-            if label.startswith('genero'):
-                for a_element in h5_element.find_all('a'):
-                    if genre := a_element.text.strip():
-                        data['genres'].append(genre)
-
-            elif label.startswith(('autor', 'artista')):
-                for a_element in h5_element.find_all('a'):
-                    author = a_element.text.strip()
-                    if author and author not in data['authors']:
-                        data['authors'].append(author)
-
-            elif label.startswith('status'):
-                status = h5_element.a.text.strip().lower()
-                if status == 'completo':
-                    data['status'] = 'complete'
-                elif status == 'ativo':
-                    data['status'] = 'ongoing'
-
-        # Synopsis
-        data['synopsis'] = info_element.find(id='manga_capitulo_descricao').text.strip()
-
-        # Chapters
-        for li_element in reversed(soup.find(id='capitulos').find_all('li')):
-            title_element = li_element.select_one('a > div.col-sm-5')
-            date_element = title_element.span.extract()
-            scanlators = None
-            if scanlator_elements := li_element.select('div > a.label.label-default'):
-                scanlators = [scanlator_element.text.strip() for scanlator_element in scanlator_elements]
-
-            data['chapters'].append(dict(
-                slug=li_element.a.get('href').split('/')[-1],
-                title=title_element.text.strip(),
-                scanlators=scanlators,
-                date=convert_date_string(date_element.text.strip()[1:-1], format='%d/%m/%Y'),
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for img_element in soup.find(id='capitulos_images').find_all('img', class_='img-manga'):
-            data['pages'].append(dict(
-                slug=img_element.get('src').split('/')[-1],
-                image=None,
-            ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(self.image_url.format(manga_slug, chapter_slug, page['slug']))
-        if r.status_code != 200:
-            return None
-
-        buffer = r.content
-        mime_type = get_buffer_mime_type(buffer)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=buffer,
-            mime_type=mime_type,
-            name=page['slug'],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    @CompleteChallenge()
-    def get_latest_updates(self):
-        r = self.session_get(self.base_url)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text.encode('utf-8'), 'lxml', from_encoding='utf-8')
-
-        results = []
-        for a_element in soup.select('.atualizacao > a'):
-            results.append(dict(
-                name=a_element.find('h3').text.strip(),
-                slug=a_element.get('href').split('/')[-1],
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def get_most_populars(self, types=None, statuses=None):
-        r = self.session_get(self.base_url)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text.encode('utf-8'), 'lxml', from_encoding='utf-8')
-
-        results = []
-        for a_element in soup.select('#capitulosdestaque > a'):
-            results.append(dict(
-                name=a_element.find_all('span')[-1].text.strip(),
-                slug=a_element.get('href').split('/')[-2],
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def search(self, term, types=None, statuses=None, orderby=None):
-        r = self.session_get(self.search_url, params=dict(busca=term))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.find_all(class_='mangas'):
-            results.append(dict(
-                name=element.h3.text.strip(),
-                slug=element.a.get('href').split('/')[-1],
-            ))
-
-        return results
diff --git a/komikku/servers/japscan/__init__.py b/komikku/servers/japscan/__init__.py
deleted file mode 100644
index 65eb4391..00000000
--- a/komikku/servers/japscan/__init__.py
+++ /dev/null
@@ -1,300 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from bs4 import BeautifulSoup
-import logging
-import requests
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.servers.utils import search_duckduckgo
-from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
-from komikku.webview import get_page_html
-
-logger = logging.getLogger('komikku.servers.japscan')
-
-
-class Japscan(Server):
-    id = 'japscan'
-    name = 'JapScan'
-    lang = 'fr'
-    long_strip_genres = ['Webtoon', ]
-    status = 'disabled'
-
-    has_cf = True
-
-    base_url = 'https://www.japscan.lol'
-    search_url = base_url + '/manga/'
-    api_search_url = base_url + '/live-search/'
-    manga_url = base_url + '/manga/{0}/'
-    chapter_url = base_url + '/lecture-en-ligne/{0}/{1}/'
-    page_url = '/lecture-en-ligne/{0}/{1}/{2}.html'
-    cover_url = base_url + '{0}'
-    bypass_cf_url = base_url + '/lecture-en-ligne/one-piece/1070/1.html'
-
-    def __init__(self):
-        self.session = None
-
-    @classmethod
-    def get_manga_initial_data_from_url(cls, url):
-        return dict(slug=url.split('/')[-2])
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r is None:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-
-        if r.status_code != 200 or mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            chapters=[],
-            server_id=self.id,
-            synopsis=None,
-        ))
-
-        card_element = soup.find_all('div', class_='card')[0]
-
-        # Main name: japscan handles several names for mangas (main + alternatives)
-        # Name provided by search can be one of the alternatives
-        # First word (Manga, Manhwa, ...) must be removed from name
-        data['name'] = ' '.join(card_element.find('h1').text.strip().split()[1:])
-        if data.get('cover') is None:
-            data['cover'] = self.cover_url.format(card_element.find('img').get('src'))
-
-        # Details
-        if not card_element.find_all('div', class_='d-flex'):
-            # mobile version
-            elements = card_element.find_all('div', class_='row')[0].find_all('p')
-        else:
-            # desktop version
-            elements = card_element.find_all('div', class_='d-flex')[0].find_all('p', class_='mb-2')
-
-        for element in elements:
-            label = element.span.text
-            element.span.extract()
-            value = element.text.strip()
-
-            if label.startswith(('Auteur', 'Artiste')):
-                for t in value.split(','):
-                    t = t.strip()
-                    if t not in data['authors']:
-                        data['authors'].append(t)
-            elif label.startswith('Genre'):
-                data['genres'] = [genre.strip() for genre in value.split(',')]
-            elif label.startswith('Statut'):
-                # Possible values: ongoing, complete
-                data['status'] = 'ongoing' if value == 'En Cours' else 'complete'
-
-        # Synopsis
-        synopsis_element = card_element.find('p', class_='list-group-item-primary')
-        if synopsis_element:
-            data['synopsis'] = synopsis_element.text.strip()
-
-        # Chapters
-        elements = soup.find('div', id='chapters_list').find_all('div', class_='chapters_list')
-        for element in reversed(elements):
-            if element.a.span:
-                span = element.a.span.extract()
-                # JapScan sometimes uploads some "spoiler preview" chapters, containing 2 or 3 untranslated pictures taken from a raw.
-                # Sometimes they also upload full RAWs/US versions and replace them with a translation as soon as available.
-                # Those have a span.badge "SPOILER", "RAW" or "VUS". We exclude these from the chapters list.
-                if span.text.strip() in ('RAW', 'SPOILER', 'VUS', ):
-                    continue
-
-            slug = element.a.get('href').split('/')[3]
-
-            data['chapters'].append(dict(
-                slug=slug,
-                title=element.a.text.strip(),
-                date=convert_date_string(element.span.text.strip(), format='%d %b %Y'),
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url, decode=True):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages and scrambled are expected.
-        """
-        html = get_page_html(self.chapter_url.format(manga_slug, chapter_slug))
-        soup = BeautifulSoup(html, 'lxml')
-
-        if reader_element := soup.find(id='full-reader'):
-            data = dict(
-                pages=[],
-            )
-
-            img_elements = reader_element.find_all('img')
-            if img_elements:
-                # Full reader (several images)
-                for img_element in img_elements:
-                    data['pages'].append(dict(
-                        url=None,
-                        slug=None,
-                        image=img_element.get('src'),
-                    ))
-            else:
-                # Single reader (single image)
-                for option_element in soup.find('select', id='pages').find_all('option'):
-                    data['pages'].append(dict(
-                        url=self.page_url.format(manga_slug, chapter_slug, int(option_element.get('value')) + 1),
-                        slug=None,
-                        image=None,
-                    ))
-
-            return data
-
-        raise requests.exceptions.RequestException
-
-    @CompleteChallenge()
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        if page['image']:
-            # We already know the image URL
-            url = page['image']
-        elif page['url']:
-            soup = BeautifulSoup(get_page_html(self.base_url + page['url']), 'lxml')
-            if reader_element := soup.find(id='single-reader'):
-                url = reader_element.img.get('src')
-            else:
-                return None
-
-        r = self.session_get(
-            url,
-            headers={
-                'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=url.split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    @CompleteChallenge()
-    def get_latest_updates(self):
-        """
-        Returns recent manga
-        """
-        r = self.session_get(self.base_url, headers={'Referer': self.base_url})
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for a_element in soup.select('#chapters > div.tab-pane:not(:last-child) > .row > div > div.row > div > a:first-child'):
-            results.append(dict(
-                name=a_element.get('title').strip(),
-                slug=a_element.get('href').split('/')[-2],
-                cover=self.base_url + a_element.img.get('src') if a_element.img else None,
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def get_most_populars(self):
-        """
-        Returns TOP manga
-        """
-        r = self.session_get(self.base_url, headers={'Referer': self.base_url})
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for li_element in soup.find('div', id='top_mangas_all_time').find_all('li'):
-            a_element = li_element.find_all('a')[0]
-            results.append(dict(
-                name=a_element.text.strip(),
-                slug=a_element.get('href').split('/')[-2],
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def search(self, term):
-        r = self.session_post(self.api_search_url, data=dict(search=term), headers={
-            'X-Requested-With': 'XMLHttpRequest',
-            'Accept': '*/*',
-            'Origin': self.base_url,
-        })
-        if r is None:
-            return None
-
-        if r.status_code == 200:
-            try:
-                data = r.json()
-
-                results = []
-                for item in data:
-                    results.append(dict(
-                        slug=item['url'].split('/')[-2],
-                        name=item['name'],
-                        cover=self.base_url + item['image'],
-                    ))
-            except Exception:
-                pass  # noqa: TC202
-            else:
-                return results
-
-        # Use DuckDuckGo Lite as fallback
-        results = []
-        for ddg_result in search_duckduckgo(self.search_url, term):
-            # Remove first word in name (Manga, Manhua, Manhwa...)
-            name = ' '.join(ddg_result['name'].split()[1:])
-            # Keep only words before "|" character
-            name = name.split('|')[0].strip()
-
-            results.append(dict(
-                name=name,
-                slug=ddg_result['url'].split('/')[-2],
-            ))
-
-        return results
diff --git a/komikku/servers/kaliscan/__init__.py b/komikku/servers/kaliscan/__init__.py
deleted file mode 100644
index f8e645fb..00000000
--- a/komikku/servers/kaliscan/__init__.py
+++ /dev/null
@@ -1,112 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2026 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import logging
-
-from bs4 import BeautifulSoup
-
-from komikku.servers.multi.madtheme import Madtheme
-from komikku.servers.utils import convert_date_string
-from komikku.webview import CompleteChallenge
-
-logger = logging.getLogger(__name__)
-
-
-class Kaliscan(Madtheme):
-    id = 'kaliscan'
-    name = 'KaliScan'
-    lang = 'en'
-
-    base_url = 'https://kaliscan.com'
-    logo_url = base_url + '/static/sites/icons/favicon-32x32.png'
-    api_base_url = base_url + '/service/backend'
-    api_chapters_url = api_base_url + '/chaplist/'
-    api_chapter_url = api_base_url + '/chapterServer/'
-
-    series_name = 'manga'
-
-    synopsis_selector = 'p.content ~ p'
-    images_selector = '.chapter-image'
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(
-            self.chapter_url.format(manga_slug, chapter_slug),
-            headers={
-                'Referer': self.manga_url.format(manga_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        sid = self.get_manga_chapter_id(soup)
-        if sid is None:
-            logger.warning('Failed to get `%s` chapter server Id', manga_name)
-            return None
-
-        r = self.session_get(
-            self.api_chapter_url,
-            params={
-                'server_id': 1,
-                'chapter_id': sid,
-            },
-            headers={
-                'Referer': self.manga_url.format(manga_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for element in soup.select(self.images_selector):
-            data['pages'].append(dict(
-                slug=None,
-                image=element.get(self.image_src_attr),
-            ))
-
-        return data
-
-    def get_manga_chapters_data(self, data, soup=None):
-        """
-        Get list of chapters
-        """
-        sid = self.get_manga_id(soup)
-        if sid is None:
-            logger.warning('Failed to get `%s` server Id', data['name'])
-            return
-
-        r = self.session_get(
-            self.api_chapters_url,
-            params={
-                'manga_id': sid,
-            },
-            headers={
-                'Referer': self.manga_url.format(data['slug']),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        for element in reversed(soup.select(self.chapters_selector)):
-            title_element = element.select_one('.chapter-title')
-            date_element = element.select_one('.chapter-update')
-
-            data['chapters'].append({
-                'slug': element.get('href').split('/')[-1],
-                'title': title_element.text.strip(),
-                'date': convert_date_string(date_element.text.strip(), languages=[self.lang]),
-            })
diff --git a/komikku/servers/mangaball/__init__.py b/komikku/servers/mangaball/__init__.py
deleted file mode 100644
index 33c031d7..00000000
--- a/komikku/servers/mangaball/__init__.py
+++ /dev/null
@@ -1,431 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from gettext import gettext as _
-import json
-
-from bs4 import BeautifulSoup
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-LANGUAGES_CODES = dict(
-    ar='ar',
-    cs='cs',
-    de='de',
-    en='en',
-    es='es',
-    es_419='es-419',
-    fa='fa',
-    fr='fr',
-    id='id',
-    it='it',
-    ja='jp',
-    ko='kr',
-    nl='nl',
-    pl='pl',
-    pt='pt-pt',
-    pt_BR='pt-br',
-    ru='ru',
-    th='th',
-    tr='tr',
-    uk='uk',
-    vi='vi',
-    zh_Hant='zh',
-    zh_Hans='zh-cn',
-)
-
-
-class Mangaball(Server):
-    id = 'mangaball'
-    name = 'MangaBall'
-    lang = 'en'
-
-    has_cf = True
-    is_nsfw = True
-
-    base_url = 'https://mangaball.net'
-    logo_url = base_url + '/public/frontend/images/favicon/favicon-32x32.png'
-
-    search_url = base_url + '/search-advanced/'
-    manga_url = base_url + '/title-detail/{0}/'
-    chapter_url = base_url + '/chapter-detail/{0}/'
-
-    api_url = base_url + '/api/v1'
-    api_search_url = api_url + '/title/search-advanced/'
-    api_chapters_url = api_url + '/chapter/chapter-listing-by-title-id/'
-
-    filters = [
-        {
-            'key': 'demographic',
-            'type': 'select',
-            'name': _('Demographic'),
-            'description': _('Filter by Publication Demographic'),
-            'value_type': 'single',
-            'default': 'any',
-            'options': [
-                {'key': 'any', 'name': _('Any')},
-                {'key': 'shounen', 'name': _('Shounen')},
-                {'key': 'shoujo', 'name': _('Shoujo')},
-                {'key': 'seinen', 'name': _('Seinen')},
-                {'key': 'josei', 'name': _('Josei')},
-                {'key': 'yuri', 'name': _('Yuri')},
-                {'key': 'yaoi', 'name': _('Yaoi')},
-            ],
-        },
-        {
-            'key': 'status',
-            'type': 'select',
-            'name': _('Status'),
-            'description': _('Filter by Status'),
-            'value_type': 'single',
-            'default': 'any',
-            'options': [
-                {'key': 'any', 'name': _('Any')},
-                {'key': 'ongoing', 'name': _('Ongoing')},
-                {'key': 'completed', 'name': _('Completed')},
-                {'key': 'hiatus', 'name': _('Hiatus')},
-                {'key': 'cancelled', 'name': _('Canceled')},
-            ]
-        },
-    ]
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data from manga HTML page
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        ))
-
-        csrf_token = soup.select_one('meta[name="csrf-token"]')['content']
-
-        data['name'] = soup.select_one('#comicDetail h6').text.strip()
-        data['cover'] = soup.select_one('.featured-cover').get('src')
-
-        if element := soup.select_one('.badge-status'):
-            status = element.text.strip()
-            if status == 'Completed':
-                data['status'] = 'complete'
-            elif status == 'Ongoing':
-                data['status'] = 'ongoing'
-            elif status == 'Hiatus':
-                data['status'] = 'hiatus'
-            elif status == 'Cancelled':
-                data['status'] = 'suspended'
-
-        for element in soup.select('[data-tag-id]'):
-            data['genres'].append(element.text.strip())
-
-        for element in soup.select('[data-person-id]'):
-            data['authors'].append(element.text.strip())
-
-        if element := soup.select_one('.description-text > p'):
-            data['synopsis'] = element.text.strip()
-
-        # Chapters
-        title_id = data['slug'].split('-')[-1]
-        r = self.session_post(
-            self.api_chapters_url,
-            data={
-                'title_id': title_id,
-                'lang': LANGUAGES_CODES[self.lang],
-            },
-            headers={
-                'Referer': self.manga_url.format(data['slug']),
-                'X-Csrf-TOKEN': csrf_token,
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        resp_data = r.json()
-
-        for chapter in reversed(resp_data['ALL_CHAPTERS']):
-            num = chapter['number_float']
-            for translation in chapter['translations']:
-                num_volume = translation.get('volume')
-                group = translation.get('group')
-
-                data['chapters'].append(dict(
-                    slug=translation['id'],
-                    title=translation['name'].strip(),
-                    scanlators=[group['name']] if group else None,
-                    num=num if is_number(num) else None,
-                    num_volume=num_volume if is_number(num_volume) else None,
-                    date=convert_date_string(translation['date'].split(' ')[0], format='%Y-%m-%d'),
-                ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data from chapter HTML page
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for script_element in soup.select('script'):
-            script = script_element.string
-            if not script or 'const chapterImages' not in script:
-                continue
-
-            for line in script.split('\n'):
-                line = line.strip()
-                if not line.startswith('const chapterImages'):
-                    continue
-
-                try:
-                    # const chapterImages = JSON.parse(`[...]`);
-                    urls = json.loads(line[34:-3])
-                    for url in urls:
-                        data['pages'].append(dict(
-                            slug=None,
-                            image=url,
-                        ))
-                except Exception:
-                    return None
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(page['image'])
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    def get_manga_list(self, term=None, demographic=None, status=None, orderby=None):
-        # CRSF token
-        r = self.session_get(self.search_url)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        csrf_token = soup.select_one('meta[name="csrf-token"]')['content']
-
-        payload = {}
-        if term:
-            payload['search_input'] = term
-
-        payload.update({
-            'filters[sort]': orderby or 'name_asc',
-            'filters[page]': '1',
-            'filters[tag_included_mode]': 'and',
-            'filters[tag_excluded_mode]': 'and',
-            'filters[contentRating]': 'any',
-            'filters[demographic]': demographic,
-            'filters[person]': 'any',
-            'filters[publicationYear]': '',
-            'filters[publicationStatus]': status,
-            'filters[translatedLanguage][]': LANGUAGES_CODES[self.lang],
-        })
-
-        r = self.session_post(
-            self.api_search_url,
-            data=payload,
-            headers={
-                'Referer': self.base_url,
-                'X-Csrf-TOKEN': csrf_token,
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        resp_data = r.json()
-
-        results = []
-        for item in resp_data['data']:
-            results.append(dict(
-                slug=item['url'].split('/')[-2],
-                name=item['name'],
-                cover=item['cover'],
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def get_latest_updates(self, demographic='any', status='any'):
-        return self.get_manga_list(demographic=demographic, status=status, orderby='updated_chapters_desc')
-
-    @CompleteChallenge()
-    def get_most_populars(self, demographic='any', status='any'):
-        return self.get_manga_list(demographic=demographic, status=status, orderby='views_desc')
-
-    @CompleteChallenge()
-    def search(self, term, demographic='any', status='any'):
-        return self.get_manga_list(term=term, demographic=demographic, status=status)
-
-
-class Mangaball_ar(Mangaball):
-    id = 'mangaball_ar'
-    lang = 'ar'
-
-
-class Mangaball_cs(Mangaball):
-    id = 'mangaball_cs'
-    lang = 'cs'
-
-
-class Mangaball_de(Mangaball):
-    id = 'mangaball_de'
-    lang = 'de'
-
-
-class Mangaball_es(Mangaball):
-    id = 'mangaball_es'
-    lang = 'es'
-
-
-class Mangaball_es_419(Mangaball):
-    id = 'mangaball_es_419'
-    lang = 'es_419'
-
-
-class Mangaball_fa(Mangaball):
-    id = 'mangaball_fa'
-    lang = 'fa'
-
-
-class Mangaball_fr(Mangaball):
-    id = 'mangaball_fr'
-    lang = 'fr'
-
-
-class Mangaball_id(Mangaball):
-    id = 'mangaball_id'
-    lang = 'id'
-
-
-class Mangaball_it(Mangaball):
-    id = 'mangaball_it'
-    lang = 'it'
-
-
-class Mangaball_ja(Mangaball):
-    id = 'mangaball_ja'
-    lang = 'ja'
-
-
-class Mangaball_ko(Mangaball):
-    id = 'mangaball_ko'
-    lang = 'ko'
-
-
-class Mangaball_nl(Mangaball):
-    id = 'mangaball_nl'
-    lang = 'nl'
-
-
-class Mangaball_pl(Mangaball):
-    id = 'mangaball_pl'
-    lang = 'pl'
-
-
-class Mangaball_pt(Mangaball):
-    id = 'mangaball_pt'
-    lang = 'pt'
-
-
-class Mangaball_pt_br(Mangaball):
-    id = 'mangaball_pt_br'
-    lang = 'pt_BR'
-
-
-class Mangaball_ru(Mangaball):
-    id = 'mangaball_ru'
-    lang = 'ru'
-
-
-class Mangaball_th(Mangaball):
-    id = 'mangaball_th'
-    lang = 'th'
-
-
-class Mangaball_tr(Mangaball):
-    id = 'mangaball_tr'
-    lang = 'tr'
-
-
-class Mangaball_uk(Mangaball):
-    id = 'mangaball_uk'
-    lang = 'uk'
-
-
-class Mangaball_vi(Mangaball):
-    id = 'mangaball_vi'
-    lang = 'vi'
-
-
-class Mangaball_zh_hant(Mangaball):
-    id = 'mangaball_zh_hant'
-    lang = 'zh_Hant'
-
-
-class Mangaball_zh_hans(Mangaball):
-    id = 'mangaball_zh_hans'
-    lang = 'zh_Hans'
diff --git a/komikku/servers/mangademon/__init__.py b/komikku/servers/mangademon/__init__.py
deleted file mode 100644
index 26a3a083..00000000
--- a/komikku/servers/mangademon/__init__.py
+++ /dev/null
@@ -1,302 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import time
-from urllib.parse import parse_qs
-from urllib.parse import urlparse
-
-from bs4 import BeautifulSoup
-
-from komikku.consts import DOWNLOAD_MAX_DELAY
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import get_response_elapsed
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-SEARCH_MAX_PAGES = 2
-
-
-class Mangademon(Server):
-    id = 'mangademon'
-    name = 'Manga Demon'
-    lang = 'en'
-    has_cf = True
-
-    base_url = 'https://demonicscans.org'
-    search_url = base_url + '/search.php'
-    latest_updates_url = base_url + '/lastupdates.php'
-    most_populars_url = base_url + '/advanced.php'
-    manga_url = base_url + '/manga/{0}'
-    chapter_url = base_url + '/chaptered.php?manga={0}&chapter={1}'
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Slug is missing in initial data'
-
-        _id, slug = initial_data['slug'].split('_')
-        r = self.session_get(self.manga_url.format(slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        )
-
-        first_chapter_url = soup.select_one('#read-first').get('href')
-        qs = parse_qs(urlparse(first_chapter_url).query)
-        id = qs['manga'][0]
-
-        data['slug'] = f'{id}_{slug}'
-        data['name'] = soup.select_one('title').text.strip()
-        if element := soup.select_one('#manga-page img'):
-            data['cover'] = element.get('src')
-
-        # Details
-        for element in soup.select('.genres-list li'):
-            genre = element.text.strip()
-            if genre not in data['genres']:
-                data['genres'].append(genre)
-
-        if element := soup.select_one('#manga-info-stats :-soup-contains("Author") li:last-child'):
-            data['authors'].append(element.text.strip())
-
-        if element := soup.select_one('#manga-info-stats :-soup-contains("Status") li:last-child'):
-            status = element.text.strip()
-            if status == 'Ongoing':
-                data['status'] = 'ongoing'
-            elif status == 'Completed':
-                data['status'] = 'complete'
-
-        # Synopsis
-        if element := soup.select_one('#manga-info-rightColumn .white-font'):
-            data['synopsis'] = element.text.strip()
-
-        # Chapters
-        chapters_slugs = []
-        for element in reversed(soup.select('#chapters-list li')):
-            url = element.a.get('href')
-            qs = parse_qs(urlparse(url).query)
-            slug = qs['chapter'][0]
-            if slug in chapters_slugs:
-                continue
-
-            data['chapters'].append(dict(
-                slug=slug,
-                title=element.a.get('title').strip(),
-                num=slug if is_number(slug) else None,
-                date=convert_date_string(element.a.span.text.strip(), format='%Y-%m-%d'),
-            ))
-            chapters_slugs.append(slug)
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        manga_id = manga_slug.split('_')[0]
-        r = self.session_get(self.chapter_url.format(manga_id, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for img_element in soup.select('div > .imgholder'):
-            data['pages'].append(dict(
-                slug=None,
-                image=img_element.get('src'),
-            ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        manga_id = manga_slug.split('_')[0]
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Referer': self.chapter_url.format(manga_id, chapter_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        _id, slug = slug.split('_')
-        return self.manga_url.format(slug)
-
-    @CompleteChallenge()
-    def get_latest_updates(self):
-        """
-        Returns latest updates
-        """
-        slugs = []
-
-        def get_page(num, slugs):
-            r = self.session.get(
-                self.latest_updates_url,
-                params=dict(
-                    list=num,
-                )
-            )
-            if r.status_code != 200:
-                return None, None, None
-
-            soup = BeautifulSoup(r.text, 'lxml')
-
-            page_results = []
-            for element in soup.select('.updates-element:not(:has(.toffee-badge))'):
-                a_element = element.select_one('.thumb > a')
-                slug = a_element.get('href').split('/')[-1]
-                if slug in slugs:
-                    continue
-                img_element = a_element.img
-                last_chapter_a_element = element.select_one('.chplinks')
-
-                page_results.append(dict(
-                    slug=f'0_{slug}',  # id is unknown at this time, use 0
-                    name=img_element.get('title').strip(),
-                    cover=img_element.get('src'),
-                    last_chapter=last_chapter_a_element.text.strip(),
-                ))
-                slugs.append(slug)
-
-            num += 1
-            more = num <= SEARCH_MAX_PAGES
-
-            return page_results, more, get_response_elapsed(r)
-
-        delay = None
-        more = True
-        page = 1
-        results = []
-        slugs = []
-        while more:
-            if delay:
-                time.sleep(delay)
-
-            page_results, more, rtime = get_page(page, slugs)
-            results += page_results
-            delay = min(rtime * 2, DOWNLOAD_MAX_DELAY) if rtime else None
-            page += 1
-
-        return results
-
-    @CompleteChallenge()
-    def get_most_populars(self):
-        """
-        Returns top views
-        """
-        def get_page(num):
-            r = self.session.get(self.most_populars_url)
-            if r.status_code != 200:
-                return None, None, None
-
-            soup = BeautifulSoup(r.text, 'lxml')
-
-            page_results = []
-            for a_element in soup.select('.advanced-element > a'):
-                slug = a_element.get('href').split('/')[-1]
-
-                page_results.append(dict(
-                    slug=f'0_{slug}',
-                    name=a_element.get('title').strip(),
-                    cover=a_element.img.get('src'),
-                ))
-
-            num += 1
-            more = num <= SEARCH_MAX_PAGES
-
-            return page_results, more, get_response_elapsed(r)
-
-        delay = None
-        more = True
-        page = 1
-        results = []
-        while more:
-            if delay:
-                time.sleep(delay)
-
-            page_results, more, rtime = get_page(page)
-            results += page_results
-            delay = min(rtime * 2, DOWNLOAD_MAX_DELAY) if rtime else None
-            page += 1
-
-        return results
-
-    @CompleteChallenge()
-    def search(self, term):
-        r = self.session_get(
-            self.search_url,
-            params={
-                'manga': term,
-            },
-            headers={
-                'Referer': f'{self.base_url}/',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for a_element in soup.select('a'):
-            slug = a_element.get('href').split('/')[-1]
-
-            results.append(dict(
-                slug=f'0_{slug}',  # id is unknown at this time, use 0
-                name=a_element.select_one('li > div > div').text.strip(),
-                cover=a_element.select_one('li > img').get('src'),
-            ))
-
-        return results
diff --git a/komikku/servers/mangafire/__init__.py b/komikku/servers/mangafire/__init__.py
deleted file mode 100644
index 93749b07..00000000
--- a/komikku/servers/mangafire/__init__.py
+++ /dev/null
@@ -1,501 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2026 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import base64
-import datetime
-from gettext import gettext as _
-import time
-from urllib.parse import urlparse
-
-from komikku.consts import DOWNLOAD_MAX_DELAY
-from komikku.servers import Server
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import get_response_elapsed
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-LANGUAGES_CODES = {
-    'en': 'en',
-    'es': 'es',
-    'es_419': 'es-la',
-    'fr': 'fr',
-    'ja': 'ja',
-    'pt': 'pt',
-    'pt_BR': 'pt-br',
-}
-SEARCH_RESULTS_PAGES = 3
-SEARCH_RESULTS_PAGE_LIMIT = 30
-CHAPTERS_PAGE_LIMIT = 100
-
-VRF_STAGES = [
-    {
-        'table': base64.b64decode('yINlmUNho8VYJT+ibTIP+9ESiULpVEtMOoD6U6lRE0R/xwXo/Xp9NrUgC4cw/Lmo33vUyjUE40kUoEWIr/fxfNNcq2s79ShQ5NhNrFnJ4hXPwOu/SuXzIbuTQKGFvfm08E9jvCfqAtoDqvQq3dVWPQFmJjgvkISBeXY3BgANR+yVnjGbcxZ47d6kLNfZPIayTq3/YGySb1KuVZodWp/WGNAO5pfMcpaK53Hhs0allBszaMaxuouOwdxbwgxIw6YunSsXjI05Yi0j9j4eHKfSXR8Ifo/Od+8iamRfCXTyvm7NGRGYdcQ0ywcK/u6RXhrbcCm4t2eCtrDgQVecJGkQ+A=='),
-        'key': base64.b64decode('0Ec58JOY3uBzJK9m3zqIOpdlF7UFiax9DmA='),
-        'iv': 0x5A,
-    },
-    {
-        'table': base64.b64decode('IUFltCxD3Oc2cwCgkJffthaOg9cgPUb0LgW6H/VtfcF0kc5F25t+aWj6JH9VOhOaY0rAFdUxlDnl5BLNvwEJvQtP5qcw7vdb/K+chnbwnspSHT8mz5lqwz41TezG0hkO06FTjJZhsyNuFLDpD2ZZxQj/QIRcF90zpmQ7Byu483WsQqUE0C342HL+JXngRB6fRzxRyVTaKu83h7UYTJ0QMt6ixFh6S3F8gqkKwrGTL3jHNBsD45UnifK8+RGtishQV2K3rujLKEkiZxpr2dYcudFW4oFsDKhad3CLBvuyTqsCo4B7mL5IKQ1vXo/MOOvq1I1d8ar9X6Ttu5KF4fZgiA=='),
-        'key': base64.b64decode('AAdjb1iPY8CiDmq9H34tKTBF8a3oDQ=='),
-        'iv': 0x35,
-    },
-    {
-        'table': base64.b64decode('NQHlu1/wVO5EmkwQymF810qqY2xG1k2obcas4Z9mCsPEIFl9pRIjFxbJ7ybMHbBckT5Ton85E0FOeHezbh/mjlEYpmpnlXOS8dgrqeq2KfxImTh1YK9y0PeMNhzA1OQzSY9brYOJq/l2QnE/hwOeZIhPixVSKIUlDb5vLcH6RWKxkIEMuP0bDwIqQ71AJJaEaMJL7A6YtyIwoRT+L5v4aZzodN/0+3nOGsfblFjgxSfPzVDjNFeNl5P26+kEC/8AHgdrpAbt3hHz3HrRN1Y6e+JHgF7ncFWnoF0y3THL1S71WgWGCa6KtSzTCCG58n68nTyj2T3Sshk7utqCtMi/ZQ=='),
-        'key': base64.b64decode('DELOJgPsVaCcblDtTGMdHzM='),
-        'iv': 0xBA,
-    },
-]
-
-
-def convert_old_slug(slug):
-    # Old format: slug.hid
-    # New format: hid-slug
-    if '.' in slug:
-        slug, hid = slug.split('.')
-        slug = f'{hid}:{slug}'
-
-    return slug if ':' in slug else None
-
-
-def compute_vrf(url, params):
-    def encrypt_stage(data, table, key, iv):
-        key_size = len(key)
-        prev = iv
-
-        out = b''
-        for index, value in enumerate(data):
-            prev = table[(value ^ key[index % key_size] ^ prev) & 0xFF] & 0xFF
-            out += prev.to_bytes()
-
-        return out
-
-    new_params = []
-    if params:
-        params = dict(sorted(params.items()))
-        for name in params.keys():
-            if name.endswith('[]'):
-                for index, value in enumerate(params[name]):
-                    new_name = name.replace('[]', f'[{index}]')
-                    new_params.append(f'{new_name}={value}')
-            else:
-                new_params.append(f'{name}={params[name]}')
-
-    data = urlparse(url).path.replace('/api', '')
-    if new_params:
-        data += '?'
-        data += '&'.join(new_params)
-
-    data = data.encode()
-    for stage in VRF_STAGES:
-        data = encrypt_stage(data, stage['table'], stage['key'], stage['iv'])
-
-    return base64.b64encode(data, altchars=b'-_').rstrip(b'=')
-
-
-class Mangafire(Server):
-    id = 'mangafire'
-    name = 'MangaFire'
-    lang = 'en'
-
-    has_cf = True
-    is_nsfw = True
-
-    base_url = 'https://mangafire.to'
-    logo_url = base_url + '/assets/mangafire/logo-sm.png'
-
-    search_url = base_url + '/browse'
-    manga_url = base_url + '/title/{0}'
-    chapter_url = base_url + 'title/{0}/chapter/{1}'
-
-    api_url = base_url + '/api'
-    api_search_url = api_url + '/titles'
-    api_manga_url = api_url + '/titles/{0}'  # hid
-    api_chapters_url = api_url + '/titles/{0}/chapters'  # hid
-    api_chapter_url = api_url + '/chapters/{0}'
-
-    filters = [
-        {
-            'key': 'content_rating',
-            'type': 'select',
-            'name': _('Content Rating'),
-            'description': _('Filter by Content Rating'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'safe', 'name': _('Safe'), 'default': True},
-                {'key': 'suggestive', 'name': _('Suggestive'), 'default': True},
-                {'key': 'erotica', 'name': _('Erotica'), 'default': False},
-                {'key': 'pornographic', 'name': _('Pornographic'), 'default': False},
-            ]
-        },
-        {
-            'key': 'types',
-            'type': 'select',
-            'name': _('Types'),
-            'description': _('Filter by Types'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'manga', 'name': _('Manga'), 'default': False},
-                {'key': 'manhwa', 'name': _('Manhwa'), 'default': False},
-                {'key': 'manhua', 'name': _('Manhua'), 'default': False},
-                {'key': 'other', 'name': _('Other'), 'default': False},
-            ]
-        },
-        {
-            'key': 'demographics',
-            'type': 'select',
-            'name': _('Demographics'),
-            'description': _('Filter by Publication Demographics'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'josei', 'name': _('Josei'), 'default': False},
-                {'key': 'seinen', 'name': _('Seinen'), 'default': False},
-                {'key': 'shoujo', 'name': _('Shoujo'), 'default': False},
-                {'key': 'shounen', 'name': _('Shounen'), 'default': False},
-            ]
-        },
-        {
-            'key': 'statuses',
-            'type': 'select',
-            'name': _('Statuses'),
-            'description': _('Filter by Statuses'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'releasing', 'name': _('Ongoing'), 'default': False},
-                {'key': 'finished', 'name': _('Completed'), 'default': False},
-                {'key': 'on_hiatus', 'name': _('Hiatus'), 'default': False},
-                {'key': 'discontinued', 'name': _('Canceled'), 'default': False},
-            ]
-        },
-    ]
-
-    long_strip_genres = [
-        'manhwa',
-    ]
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data using API
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Slug is missing in initial data'
-
-        if ':' not in initial_data['slug']:
-            if slug := convert_old_slug(initial_data['slug']):
-                initial_data['slug'] = slug
-            else:
-                # Invalid/obsolete slug
-                return None
-
-        hid, slug = initial_data['slug'].split(':')
-        url = self.api_manga_url.format(hid)
-
-        r = self.session_get(
-            url,
-            params={
-                'vrf': compute_vrf(url, None),
-            },
-            headers={
-                'Accept': 'application/json',
-                'Referer': self.get_manga_url(initial_data['slug'], None),
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        resp_data = r.json()['data']
-
-        data = initial_data.copy()
-        data.update({
-            'name': resp_data['title'],
-            'authors': [],
-            'scanlators': [],  # not available?
-            'genres': [],
-            'status': None,
-            'synopsis': None,
-            'chapters': [],
-            'server_id': self.id,
-            'cover': None,
-        })
-
-        if resp_data.get('poster') and resp_data['poster'].get('medium'):
-            data['cover'] = resp_data['poster']['medium']
-
-        for author in resp_data.get('authors', []):
-            data['authors'].append(author['title'])
-        for artist in resp_data.get('artists', []):
-            artist = artist['title']
-            if artist not in data['authors']:
-                data['authors'].append(artist.strip())
-
-        if resp_data['status'] == 'releasing':
-            data['status'] = 'ongoing'
-        elif resp_data['status'] == 'finished':
-            data['status'] = 'complete'
-        elif resp_data['status'] == 'on_hiatus':
-            data['status'] = 'hiatus'
-        elif resp_data['status'] == 'discontinued':
-            data['status'] = 'suspended'
-
-        for genre in resp_data.get('genres', []):
-            data['genres'].append(genre['title'])
-        for demographic in resp_data.get('demographics', []):
-            data['genres'].append(demographic['title'])
-        if type_ := resp_data.get('type'):
-            data['genres'].append(type_)
-
-        if synopsis := resp_data.get('synopsisHtml'):
-            synopsis = synopsis.replace('<br>', '')
-            synopsis = synopsis.replace('target="_blank"', '')
-            synopsis = synopsis.replace('rel="noopener noreferrer"', '')
-            data['synopsis'] = synopsis.strip()
-
-        data['chapters'] = self.get_manga_chapters_data(data['slug'])
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns chapter data using API
-
-        Currently, only pages are expected.
-        """
-        url = self.api_chapter_url.format(chapter_slug)
-
-        r = self.session_get(
-            url,
-            params={
-                'vrf': compute_vrf(url, None),
-            },
-            headers={
-                'Accept': 'application/json',
-                'Referer': self.chapter_url.format(manga_slug.replace(':', '-'), chapter_slug),
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        data = {
-            'pages': [],
-        }
-        for index, page in enumerate(r.json()['data']['pages'], start=1):
-            data['pages'].append({
-                'slug': None,
-                'image': page['url'],
-                'index': index,
-            })
-
-        return data
-
-    def get_manga_chapters_data(self, slug, page=1, chapters=None):
-        if chapters is None:
-            chapters = []
-
-        hid, _slug = slug.split(':')
-        url = self.api_chapters_url.format(hid)
-        params = {
-            'language': LANGUAGES_CODES[self.lang],
-            'order': 'desc',
-            'page': page,
-            'limit': CHAPTERS_PAGE_LIMIT,
-        }
-        params['vrf'] = compute_vrf(url, params)
-
-        r = self.session_get(
-            url,
-            params=params,
-            headers={
-                'Accept': 'application/json',
-                'Referer': self.get_manga_url(slug, None),
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        rtime = get_response_elapsed(r)
-        resp_data = r.json()
-
-        for chapter in resp_data['items']:
-            title = []
-            if num := chapter.get('number'):
-                title.append(f'Ch. {num}')
-            if name := chapter.get('name'):
-                title.append(name)
-            type_ = chapter.get('type')
-
-            chapters.append({
-                'slug': chapter['id'],
-                'title': ' '.join(title),
-                'scanlators': [type_] if type_ else None,
-                'num': num if is_number(num) else None,
-                'date': datetime.datetime.fromtimestamp(chapter['createdAt']).date(),
-            })
-
-        if resp_data['meta']['hasNext']:
-            if rtime:
-                time.sleep(min(rtime * 4, DOWNLOAD_MAX_DELAY))
-
-            self.get_manga_chapters_data(slug, page=page + 1, chapters=chapters)
-
-        return list(reversed(chapters))
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Referer': f'{self.base_url}/',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return {
-            'buffer': r.content,
-            'mime_type': mime_type,
-            'name': '{0:03d}.{1}'.format(page['index'], mime_type.split('/')[-1]),
-        }
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        slug = slug.replace(':', '-')
-        return self.manga_url.format(slug)
-
-    def get_manga_list(self, term=None, content_rating=None, types=None, demographics=None, statuses=None, orderby=None):
-        def get_page(page):
-            params = {}
-            if term:
-                params['keyword'] = term
-
-            if content_rating:
-                params['content_rating[]'] = content_rating
-            if types:
-                params['types[]'] = types
-            if demographics:
-                params['demographics[]'] = demographics
-            if statuses:
-                params['statuses[]'] = statuses
-
-            if orderby == 'popular':
-                params['order[trending]'] = 'desc'
-            elif orderby == 'latest':
-                params['order[chapter_updated_at]'] = 'desc'
-            else:
-                params['order[relevance]'] = 'desc'
-
-            params.update({
-                'page': page,
-                'limit': SEARCH_RESULTS_PAGE_LIMIT,
-            })
-
-            params['vrf'] = compute_vrf(self.api_search_url, params)
-
-            r = self.session_get(
-                self.api_search_url,
-                params=params,
-                headers={
-                    'Accept': 'application/json',
-                    'Referer': self.search_url,
-                    'X-Requested-With': 'XMLHttpRequest',
-                }
-            )
-            if r.status_code != 200:
-                return [], False, None
-
-            resp_data = r.json()
-
-            more = resp_data['meta']['hasNext'] and page < SEARCH_RESULTS_PAGES
-
-            return resp_data['items'], more, get_response_elapsed(r)
-
-        results = []
-        delay = None
-        more = True
-        page = 1
-        while more:
-            if delay:
-                time.sleep(delay)
-
-            items, more, rtime = get_page(page)
-            for item in items:
-                results.append({
-                    'slug': f'{item["hid"]}:{item["slug"]}',  # noqa
-                    'name': item['title'],
-                    'cover': item['poster']['medium'],
-                    'last_chapter': item.get('latestChapter'),
-                })
-
-            delay = min(rtime * 4, DOWNLOAD_MAX_DELAY) if rtime else None
-            page += 1
-
-        return results
-
-    @CompleteChallenge()
-    def get_latest_updates(self, content_rating=None, types=None, demographics=None, statuses=None):
-        return self.get_manga_list(
-            content_rating=content_rating, types=types, demographics=demographics, statuses=statuses, orderby='latest'
-        )
-
-    @CompleteChallenge()
-    def get_most_populars(self, content_rating=None, types=None, demographics=None, statuses=None):
-        return self.get_manga_list(
-            content_rating=content_rating, types=types, demographics=demographics, statuses=statuses, orderby='popular'
-        )
-
-    @CompleteChallenge()
-    def search(self, term, content_rating=None, types=None, demographics=None, statuses=None):
-        return self.get_manga_list(
-            term=term, content_rating=content_rating, types=types, demographics=demographics, statuses=statuses
-        )
-
-
-class Mangafire_es(Mangafire):
-    id = 'mangafire_es'
-    name = 'MangaFire'
-    lang = 'es'
-
-
-class Mangafire_es_419(Mangafire):
-    id = 'mangafire_es_419'
-    name = 'MangaFire'
-    lang = 'es_419'
-
-
-class Mangafire_fr(Mangafire):
-    id = 'mangafire_fr'
-    name = 'MangaFire'
-    lang = 'fr'
-
-
-class Mangafire_ja(Mangafire):
-    id = 'mangafire_ja'
-    name = 'MangaFire'
-    lang = 'ja'
-
-
-class Mangafire_pt(Mangafire):
-    id = 'mangafire_pt'
-    name = 'MangaFire'
-    lang = 'pt'
-
-
-class Mangafire_pt_br(Mangafire):
-    id = 'mangafire_pt_br'
-    name = 'MangaFire'
-    lang = 'pt_BR'
diff --git a/komikku/servers/mangafreak/__init__.py b/komikku/servers/mangafreak/__init__.py
deleted file mode 100644
index 054f43aa..00000000
--- a/komikku/servers/mangafreak/__init__.py
+++ /dev/null
@@ -1,241 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from bs4 import BeautifulSoup
-import logging
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-logger = logging.getLogger('komikku.servers.mangafreak')
-
-
-class Mangafreak(Server):
-    id = 'mangafreak'
-    name = 'MangaFreak'
-    lang = 'en'
-    is_nsfw = True
-
-    has_cf = True
-
-    base_url = 'https://ww2.mangafreak.me'
-    most_populars_url = base_url
-    latest_updates_url = base_url + '/Latest_Releases'
-    search_url = base_url + '/Find/{term}'
-    manga_url = base_url + '/Manga/{slug}'
-    chapter_url = base_url + '/{chapter_slug}'
-    image_url = 'https://images.mangafreak.me/mangas/{slug}'
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(slug=initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-        ))
-
-        details_container_element = soup.find('div', class_='manga_series_data')
-
-        # Name & cover
-        data['name'] = details_container_element.h1.text.strip()
-        data['cover'] = soup.find('div', class_='manga_series_image').img.get('src')
-
-        # Details
-        for element in details_container_element.select('div'):
-            text_split = element.text.strip().split(':')
-            if len(text_split) != 2:
-                continue
-
-            label, value = list(map(str.strip, text_split))
-            if label == 'Status':
-                if value == 'COMPLETED':
-                    data['status'] = 'complete'
-                elif value == 'ON-GOING':
-                    data['status'] = 'ongoing'
-
-            elif label in ('Author', 'Artist'):
-                if value not in data['authors']:
-                    data['authors'].append(value)
-
-        for a_element in details_container_element.select('.series_sub_genre_list > a'):
-            data['genres'].append(a_element.text.strip())
-
-        # Synopsis
-        data['synopsis'] = soup.find('div', class_='manga_series_description').p.text.strip()
-
-        # Chapters
-        for tr_element in soup.find('div', class_='manga_series_list').find_all('tr'):
-            tds_elements = tr_element.find_all('td')
-
-            slug = tds_elements[0].a.get('href').split('/')[-1]
-            title = tds_elements[0].a.text.strip()
-            num = slug.split('_')[-1]
-            date = tds_elements[1].text.strip()
-
-            data['chapters'].append(dict(
-                slug=slug,
-                title=title,
-                num=num if is_number(num) else None,
-                date=convert_date_string(date, format='%Y/%m/%d'),
-            ))
-
-        return data
-
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(chapter_slug=chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for div_element in soup.find('div', class_='slideshow-container').find_all('div', class_='mySlides'):
-            data['pages'].append(dict(
-                slug='/'.join(div_element.img.get('src').split('/')[-3:]),
-                image=None,
-            ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            self.image_url.format(slug=page['slug']),
-            headers={
-                'referer': self.chapter_url.format(chapter_slug=chapter_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['slug'].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug=slug)
-
-    @CompleteChallenge()
-    def get_latest_updates(self):
-        """
-        Returns latest released
-        """
-        r = self.session_get(self.latest_updates_url)
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.select('.latest_releases_item'):
-            img_element = element.select_one('.latest_releases_item img')
-            a_element = element.select_one('.latest_releases_info a')
-            results.append(dict(
-                name=a_element.text.strip(),
-                slug=a_element.get('href').split('/')[-1],
-                cover=img_element.get('src'),
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def get_most_populars(self):
-        """
-        Returns featured manga list
-        """
-        r = self.session_get(self.most_populars_url)
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.select('.featured_item'):
-            img_element = element.select_one('.featured_item_image a img')
-            a_element = element.select_one('.featured_item_info a')
-            results.append(dict(
-                name=a_element.text.strip(),
-                slug=a_element.get('href').split('/')[-1],
-                cover=img_element.get('src'),
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def search(self, term):
-        r = self.session_get(self.search_url.format(term=term))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for item in soup.find_all('div', class_='manga_search_item'):
-            a_element = item.find('h3').a
-            results.append(dict(
-                slug=a_element.get('href').strip().split('/')[-1],
-                name=a_element.text.strip(),
-                cover=item.span.a.img.get('src'),
-            ))
-
-        return results
diff --git a/komikku/servers/mangahere/__init__.py b/komikku/servers/mangahere/__init__.py
deleted file mode 100644
index 46bb15b7..00000000
--- a/komikku/servers/mangahere/__init__.py
+++ /dev/null
@@ -1,289 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import datetime
-import json
-from urllib.parse import urlparse
-
-from bs4 import BeautifulSoup
-import requests
-
-from komikku.consts import USER_AGENT
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import eval_js
-
-
-class Mangahere(Server):
-    id = 'mangahere'
-    name = 'MangaHere'
-    lang = 'en'
-    is_nsfw = True
-
-    base_url = 'https://www.mangahere.cc'
-    logo_url = base_url + '/favicon.ico'
-    search_url = base_url + '/search'
-    latest_updates_url = base_url + '/latest/'
-    most_populars_url = base_url + '/ranking/'
-    manga_url = base_url + '/manga/{0}/'
-    chapter_url = base_url + '/manga/{0}/{1}/'
-    page_url = base_url + '/manga/{0}/{1}/{2}.html'
-
-    def __init__(self):
-        if self.session is None:
-            self.session = requests.Session()
-            self.session.headers = {
-                'User-Agent': USER_AGENT,
-            }
-            cookie = requests.cookies.create_cookie(
-                name='isAdult',
-                value='1',
-                domain=urlparse(self.base_url).netloc,
-                path='/',
-                expires=(datetime.datetime.now() + datetime.timedelta(days=1)).timestamp(),
-            )
-            self.session.cookies.set_cookie(cookie)
-
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        if r.url.startswith(self.search_url):
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        ))
-
-        data['name'] = soup.select_one('.detail-info-right-title-font').text.strip()
-        data['cover'] = soup.select_one('.detail-info-cover-img').get('src')
-
-        # Details
-        status = soup.select_one('.detail-info-right-title-tip').text.strip().lower()
-        data['status'] = 'ongoing' if status == 'ongoing' else 'complete'
-        for a_element in soup.select('.detail-info-right-say a'):
-            data['authors'].append(a_element.text.strip())
-        for a_element in soup.select('.detail-info-right-tag-list a'):
-            data['genres'].append(a_element.text.strip())
-
-        # Synopsis
-        data['synopsis'] = soup.select_one('.detail-info-right-content').text.strip()
-
-        # Chapters
-        for a_element in reversed(soup.select('.detail-main-list > li > a')):
-            num = None
-            num_volume = None
-            slug = a_element.get('href').split('/')[3:-1]
-            for part in slug:
-                if part[0] == 'c':
-                    # cXXX
-                    num = part[1:]
-                elif part[0] == 'v':
-                    # vYY
-                    num_volume = part[1:]
-
-            data['chapters'].append(dict(
-                slug='/'.join(slug),  # cXXX or vYY/cXXX
-                title=a_element.get('title').replace(data['name'], '').strip(),
-                num=num if is_number(num) else None,
-                num_volume=num_volume if is_number(num_volume) else None,
-                date=convert_date_string(a_element.select_one('.title2').text.strip(), format='%b %d,%Y'),
-            ))
-
-        return data
-
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        for script_element in soup.find_all('script'):
-            script = script_element.string
-            if script is None:
-                continue
-
-            if script.strip().startswith('eval(function(p,a,c,k,e,d)'):
-                js_code = 'pasglop = ' + script.strip()[5:-1] + '; pasglop'
-
-            elif 'var chapterid' in script:
-                # Get chapter ID, required with page by page reader
-                for line in script.split(';'):
-                    if not line.strip().startswith('var imagecount'):
-                        continue
-
-                    nb_pages = int(line.strip().split('=')[-1].strip())
-                    break
-
-        data = dict(
-            pages=[],
-        )
-
-        if len(soup.select('.reader-main > div')) == 0:
-            # Webtoon reader
-            res = eval_js(js_code)
-            # We obtain something like this
-            # var newImgs=['url1','url2',...];blabla...;
-            pages_urls = json.loads(res[12:].split(';')[0].replace("'", '"'))
-
-            for url in pages_urls:
-                data['pages'].append(dict(
-                    image=f'https:{url}',  # noqa: E231
-                ))
-        else:
-            # Page by page reader
-            for index in range(1, nb_pages):
-                data['pages'].append(dict(
-                    index=index,
-                ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        if page.get('index'):
-            index = page['index']
-            r = self.session_get(self.page_url.format(manga_slug, chapter_slug, index))
-            if r.status_code != 200:
-                return None
-
-            mime_type = get_buffer_mime_type(r.content)
-            if mime_type != 'text/html':
-                return None
-
-            soup = BeautifulSoup(r.text, 'lxml')
-
-            # We need a secret key and the chapter ID
-            for script_element in soup.find_all('script'):
-                script = script_element.string
-                if script is None:
-                    continue
-
-                if script.strip().startswith('eval(function(p,a,c,k,e,d)'):
-                    js_code = 'pasglop = ' + script.strip()[5:-1] + '; pasglop'
-
-                elif 'var chapterid' in script:
-                    # Get chapter ID
-                    for line in script.split(';'):
-                        if line.strip().startswith('var chapterid'):
-                            cid = line.strip().split('=')[-1].strip()
-                            break
-
-            res = eval_js(js_code)
-            # We obtain something like this
-            # var guidkey=''+'3'+'a'+'e'+'d'+'6'+'a'+'a'+'6'+'1'+'0'+'4'+'e'+'7'+'2'+'3'+'e';blabla...;
-            key = res[13:].split(';')[0][:-1].replace("'+'", '')
-
-            r = self.session_get(
-                self.chapter_url.format(manga_slug, chapter_slug) + f'chapterfun.ashx?cid={cid}&page={index}&key={key}',
-                headers={
-                    'Referer': self.page_url.format(manga_slug, chapter_slug, index),
-                    'X-Requested-With': 'XMLHttpRequest',
-                }
-            )
-
-            js_code = 'pasglop = ' + r.text[5:-2] + '; pasglop'
-            res = eval_js(js_code)
-            # We obtain something like this
-            # function dm5imagefun(){var pix="//the_base_url";var pvalue=["path_image","path_next_image"];blabla...;
-            base_url = res[23:-1].split(';')[0][9:-1]
-            images = json.loads(res.split(';')[1][11:])
-
-            url = f'https:{base_url}{images[0]}'  # noqa: E231
-        else:
-            url = page['image']
-
-        r = self.session_get(url, headers={'Referer': self.base_url})
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=url.split('/')[-1].split('_')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    def get_latest_updates(self):
-        """
-        Returns Latest Updates
-        """
-        return self.search(None, orderby='latest')
-
-    def get_most_populars(self):
-        """
-        Returns Most Popular
-        """
-        return self.search(None, orderby='populars')
-
-    def search(self, term, orderby=None):
-        if term:
-            r = self.session_get(self.search_url, params=dict(title=term))
-        elif orderby == 'latest':
-            r = self.session_get(self.latest_updates_url)
-        elif orderby == 'populars':
-            r = self.session_get(self.most_populars_url)
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for li_element in soup.select('.line-list > div> ul > li'):
-            results.append(dict(
-                slug=li_element.a.get('href').split('/')[-2],
-                name=li_element.a.get('title').strip(),
-                cover=li_element.a.img.get('src'),
-            ))
-
-        return results
diff --git a/komikku/servers/mangakawaii/__init__.py b/komikku/servers/mangakawaii/__init__.py
deleted file mode 100644
index db77e958..00000000
--- a/komikku/servers/mangakawaii/__init__.py
+++ /dev/null
@@ -1,425 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from functools import wraps
-import json
-from urllib.parse import urlparse
-
-from bs4 import BeautifulSoup
-import requests
-
-from komikku.consts import USER_AGENT
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-SERVER_NAME = 'MangaKawaii'
-
-
-def set_lang(func):
-    @wraps(func)
-    def wrapper(*args, **kwargs):
-        server = args[0]
-        if not server.is_lang_set:
-            server.session_get(
-                server.base_url + '/lang/' + server.lang,
-                params={
-                    'Referer': f'{server.base_url}/',
-                }
-            )
-            server.is_lang_set = True
-
-        return func(*args, **kwargs)
-
-    return wrapper
-
-
-class Mangakawaii(Server):
-    id = 'mangakawaii'
-    name = SERVER_NAME
-    lang = 'fr'
-    has_cf = True
-    is_nsfw = True
-    long_strip_genres = ['Webtoon', ]
-
-    base_url = 'https://www.mangakawaii.io'
-    logo_url = base_url + '/assets/img/favicon-32x32.png'
-    search_url = base_url + '/recherche-manga'
-    manga_list_url = base_url + '/filterMangaList'
-    manga_list_referer_url = base_url + '/liste-manga'
-    manga_url = base_url + '/manga/{0}'
-    chapter_url = base_url + '/manga/{0}/{1}/{2}/1'
-    chapters_url = base_url + '/loadChapter?page={0}'
-    cdn_base_urls = [
-        'https://cdn.mangakawaii.io',
-        'https://cdn2.mangakawaii.io',
-    ]
-    image_url = '{0}/uploads/manga/{1}/chapters_{2}/{3}/{4}?{5}'
-    cover_url = cdn_base_urls[0] + '/uploads/manga/{0}/cover/cover_250x350.jpg'
-    bypass_cf_url = base_url + '/manga/martial-peak'
-
-    csrf_token = None
-    is_lang_set = False
-
-    def __init__(self):
-        if self.session is None:
-            self.session = requests.Session()
-            cookie = requests.cookies.create_cookie(
-                name='mk_search_type',
-                value='manga',
-                domain=urlparse(self.base_url).netloc,
-                path='/',
-                expires=None,
-            )
-            self.session.cookies.set_cookie(cookie)
-            cookie = requests.cookies.create_cookie(
-                name='mk_cookie_consent',
-                value='1',
-                domain=urlparse(self.base_url).netloc,
-                path='/',
-                expires=None,
-            )
-            self.session.cookies.set_cookie(cookie)
-
-            self.session.headers.update({'User-Agent': USER_AGENT})
-
-    @CompleteChallenge()
-    @set_lang
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        self.csrf_token = soup.select_one('meta[name="csrf-token"]')['content']
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-        ))
-
-        data['name'] = soup.find('h1').text.strip()
-        if data.get('cover') is None:
-            data['cover'] = self.cover_url.format(data['slug'])
-
-        # Details
-        elements = soup.find('div', class_='col-md-8 mt-4 mt-md-0').find_all('dl')
-        for element in elements:
-            label = element.dt.text.strip()
-
-            if label.startswith(('Author', 'Auteur', 'Artist', 'Artiste')):
-                value = element.dd.span.text.strip()
-                for t in value.split(','):
-                    t = t.strip()
-                    if t not in data['authors']:
-                        data['authors'].append(t)
-
-            elif label.startswith('Scantrad'):
-                for a_element in element.dd.find_all('a', itemprop='name'):
-                    data['scanlators'].append(a_element.text.replace('[', '').replace(']', '').strip())
-
-            elif label.startswith('Genres'):
-                a_elements = element.dd.find_all('a')
-                data['genres'] = [a_element.text.strip() for a_element in a_elements]
-
-            elif label.startswith(('Status', 'Statut')):
-                status = element.dd.span.text.strip().lower()
-                if status in ('ongoing', 'en cours'):
-                    data['status'] = 'ongoing'
-                elif status in ('completed', 'terminé'):
-                    data['status'] = 'complete'
-                elif status in ('abandoned', 'abandonné'):
-                    data['status'] = 'suspended'
-                elif status in ('paused', 'en pause'):
-                    data['status'] = 'hiatus'
-
-            elif label.startswith(('Summary', 'Description')):
-                data['synopsis'] = element.dd.text.strip()
-
-        #
-        # Chapters
-        # They are displayed in reverse order and loaded by page (if many)
-        #
-        # First, we get the oeuvreId
-        oeuvre_id = None
-        for script_element in reversed(soup.find_all('script')):
-            script = script_element.string
-            if not script or not script.strip().startswith('var ENDPOINT'):
-                continue
-
-            for line in script.split('\n'):
-                line = line.strip()
-                if not line.startswith('var oeuvreId'):
-                    continue
-
-                oeuvre_id = line.split("'")[-2]
-                break
-
-            break
-
-        # Next, we retrieve first chapters available in page's HTML
-        for tr_element in soup.find('table', class_='table--manga').find_all('tr'):
-            td_element = tr_element.find('td', class_='table__chapter')
-            if not td_element:
-                continue
-
-            slug = td_element.a.get('href').strip().split('/')[-1]
-            date_element = tr_element.find('td', class_='table__date')
-            if date_element.div:
-                date_element.div.extract()
-
-            data['chapters'].append(dict(
-                slug=slug,
-                title=' '.join(td_element.a.span.text.strip().split()),
-                num=slug if is_number(slug) else None,
-                date=convert_date_string(date_element.text.strip(), format='%d.%m.%Y'),
-            ))
-
-        # Finally, we recursively retrieve other chapters by page (via a web service)
-        data['chapters'] += self.get_manga_chapters_data(data['slug'], oeuvre_id)
-        data['chapters'].reverse()
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, self.lang, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for script_element in reversed(soup.find_all('script')):
-            script = script_element.string
-            if not script or not script.strip().startswith('var title'):
-                continue
-
-            for line in script.split('\n'):
-                line = line.strip()
-                if not line.startswith('var pages'):
-                    continue
-
-                pages = json.loads(line[12:-1])
-                for index, page in enumerate(pages):
-                    data['pages'].append(dict(
-                        slug=None,
-                        image=page['page_image'],
-                        index=index,
-                        version=page['page_version'],
-                    ))
-
-                break
-
-        return data
-
-    def get_manga_chapters_data(self, manga_slug, oeuvre_id, page=2):
-        r = self.session_post(
-            self.chapters_url.format(page),
-            data=dict(
-                oeuvreType='manga',
-                oeuvreId=oeuvre_id,
-                oeuvreSlug=manga_slug,
-                oeuvreDownload='0',
-            ),
-            headers={
-                'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
-                'Referer': self.manga_url.format(manga_slug),
-                'X-CSRF-TOKEN': self.csrf_token,
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return []
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return []
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        chapters = []
-        for tr_element in soup.find_all('tr'):
-            td_element = tr_element.find('td', class_='table__chapter')
-            if not td_element:
-                continue
-
-            slug = td_element.a.get('href').strip().split('/')[-1]
-            date_element = tr_element.find('td', class_='table__date')
-            if date_element.div:
-                date_element.div.extract()
-
-            chapters.append(dict(
-                slug=slug,
-                title=' '.join(td_element.a.span.text.strip().split()),
-                num=slug if is_number(slug) else None,
-                date=convert_date_string(date_element.text.strip(), format='%d.%m.%Y'),
-            ))
-
-        chapters += self.get_manga_chapters_data(manga_slug, oeuvre_id, page + 1)
-
-        return chapters
-
-    @CompleteChallenge()
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        success = False
-        for cdn_base_url in self.cdn_base_urls:
-            r = self.session_get(
-                self.image_url.format(cdn_base_url, manga_slug, self.lang, chapter_slug, page['image'], page['version']),
-                headers={
-                    'Referer': self.base_url,
-                }
-            )
-            if r.ok:
-                success = True
-                break
-
-        if not success:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('?')[0].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    def get_manga_list(self, orderby):
-        """
-        Returns manga list sorted
-        """
-        params = dict(
-            page=1,
-            cat='',
-            alpha='',
-            asc='false',
-            author='',
-        )
-        params['sortBy'] = 'views' if orderby == 'populars' else 'last_updated'
-
-        r = self.session_get(
-            self.manga_list_url,
-            params=params,
-            headers={
-                'X-Requested-With': 'XMLHttpRequest',
-                'Referer': self.manga_list_url,
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/plain':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.find_all('div', class_='media-thumbnail'):
-            slug = element.find('a').get('href').split('/')[-1]
-            results.append(dict(
-                name=element.find('div', class_='media-thumbnail__overlay').find('h3').text.strip(),
-                slug=slug,
-                cover=self.cover_url.format(slug),
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    @set_lang
-    def get_latest_updates(self):
-        """
-        Returns latest updates
-        """
-        return self.get_manga_list('latest')
-
-    @CompleteChallenge()
-    @set_lang
-    def get_most_populars(self):
-        """
-        Returns list of most viewed manga
-        """
-        return self.get_manga_list('populars')
-
-    @CompleteChallenge()
-    @set_lang
-    def search(self, term):
-        r = self.session_get(
-            self.search_url,
-            params=dict(query=term),
-            headers={
-                'X-Requested-With': 'XMLHttpRequest',
-                'Referer': self.base_url,
-            }
-        )
-
-        if r.status_code == 200:
-            try:
-                # Returned data for each manga:
-                # value: name of the manga
-                # slug: slug of the manga
-                # imageUrl: cover of the manga
-                data = r.json()['suggestions']
-
-                results = []
-                for item in data:
-                    results.append(dict(
-                        slug=item['slug'],
-                        name=item['value'],
-                        cover=self.cover_url.format(item['slug']),
-                    ))
-            except Exception:
-                return None
-            else:
-                return results
-
-        return None
-
-
-class Mangakawaii_en(Mangakawaii):
-    id = 'mangakawaii_en'
-    lang = 'en'
diff --git a/komikku/servers/mangamana/__init__.py b/komikku/servers/mangamana/__init__.py
deleted file mode 100644
index 112af834..00000000
--- a/komikku/servers/mangamana/__init__.py
+++ /dev/null
@@ -1,296 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from functools import wraps
-from gettext import gettext as _
-import json
-import re
-
-from bs4 import BeautifulSoup
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.servers.utils import get_soup_element_inner_text
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-RE_CHAPTER_PAGES = r'.*pages\s+=\s+([a-zA-Z0-9":,-_.\[\]{}]+);.*'
-RE_CHAPTER_PAGES_CDN = r'.*var\s+cdn\s+=\s+"([a-z1-9]+[^"])";.*'
-
-
-def get_data(func):
-    @wraps(func)
-    def wrapper(*args, **kwargs):
-        server = args[0]
-        if server.csrf_token:
-            return func(*args, **kwargs)
-
-        r = server.session_get(server.base_url)
-        if r.status_code != 200:
-            return func(*args, **kwargs)
-
-        soup = BeautifulSoup(r.text, 'lxml')
-        server.csrf_token = soup.select_one('meta[name="csrf-token"]')['content']
-
-        return func(*args, **kwargs)
-
-    return wrapper
-
-
-class Mangamana(Server):
-    id = 'mangamana'
-    name = 'Manga Mana'
-    lang = 'fr'
-
-    has_cf = True
-
-    base_url = 'https://www.manga-mana.com'
-    logo_url = base_url + '/favicon-32x32.png'
-    search_url = base_url + '/search-live'
-    manga_list_url = base_url + '/liste-mangas'
-    manga_url = base_url + '/m/{0}'
-    chapter_url = base_url + '/m/{0}/{1}'
-    image_url = 'https://{0}.manga-mana.com/uploads/manga/{1}/chapters_fr/{2}/{3}?{4}'
-    cover_url = 'https://cdn.manga-mana.com/uploads/manga/{0}/cover/cover_thumb.jpg'
-
-    filters = [
-        {
-            'key': 'status',
-            'type': 'select',
-            'name': _('Status'),
-            'description': _('Status'),
-            'value_type': 'single',
-            'default': None,
-            'options': [
-                {'key': '1', 'name': _('Ongoing')},
-                {'key': '2', 'name': _('Complete')},
-                {'key': '3', 'name': _('Suspended')},
-            ]
-        },
-    ]
-
-    csrf_token = None
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=self.cover_url.format(data['slug']),
-        ))
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data['name'] = soup.select_one('h1').text.strip()
-
-        # Details
-        if element := soup.select_one('.show_details :-soup-contains("Statut") > span'):
-            status = element.text.strip()
-            if status == 'En Cours':
-                data['status'] = 'ongoing'
-            elif status == 'Terminé':
-                data['status'] = 'complete'
-            elif status == 'Abandonné':
-                data['status'] = 'suspended'
-
-        for element in soup.select('.show_details span[itemprop="author"] > a'):
-            author = element.text.strip()
-            data['authors'].append(author)
-        for element in soup.select('.show_details span[itemprop="illustrator"] > a'):
-            artist = element.text.strip()
-            if artist not in data['authors']:
-                data['authors'].append(artist)
-        for element in soup.select('.show_details span[itemprop="translator"] > span'):
-            scanlator = element.text.strip()
-            data['scanlators'].append(scanlator)
-
-        for element in soup.select('a[itemprop="genre"]'):
-            genre = element.text.strip()
-            data['genres'].append(genre)
-
-        if element := soup.select_one('dd[itemprop="description"]'):
-            data['synopsis'] = get_soup_element_inner_text(element, recursive=False)
-            if more_element := element.select_one('#more'):
-                data['synopsis'] += ' ' + more_element.text.strip()
-
-        # Chapters
-        for a_element in reversed(soup.select('.chapter_link')):
-            slug = a_element.get('href').split('/')[-1]
-
-            data['chapters'].append(dict(
-                slug=slug,
-                title=get_soup_element_inner_text(a_element.div.div, recursive=False),
-                num=slug if is_number(slug) else None,
-                date=convert_date_string(a_element.div.div.div.text.strip(), languages=['fr']),
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chpater HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        cdn = None
-        pages = None
-        for script_element in soup.find_all('script'):
-            script = script_element.string
-            if not script or 'var pages' not in script:
-                continue
-
-            for line in script.split('\n'):
-                if matches := re.search(RE_CHAPTER_PAGES, line):
-                    pages = json.loads(matches.group(1))
-                if matches := re.search(RE_CHAPTER_PAGES_CDN, line):
-                    cdn = matches.group(1)
-
-            if cdn and pages:
-                break
-
-        if not cdn or not pages:
-            return None
-
-        data = dict(
-            pages=[],
-        )
-        for page in pages:
-            data['pages'].append(dict(
-                slug=None,
-                image=self.image_url.format(cdn, manga_slug, chapter_slug, page['image'], page['version']),
-            ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Referer': f'{self.base_url}/',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    @get_data
-    def get_manga_list(self, orderby, status):
-        r = self.session_post(
-            self.manga_list_url,
-            data={
-                'category': '',
-                'status': status or '',
-                'sort_by': orderby,
-                'sort_dir': 'desc',
-            },
-            headers={
-                'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
-                'Referer': self.manga_list_url,
-                'X-CSRF-TOKEN': self.csrf_token,
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        data = r.json()
-        if not data['success']:
-            return None
-
-        soup = BeautifulSoup(data['html'], 'lxml')
-
-        result = []
-        for element in soup.select('.mangalist_item'):
-            a_element = element.select_one('div:nth-child(2) > div > a')
-            slug = a_element.get('href').split('/')[-1]
-            if not slug:
-                continue
-
-            result.append(dict(
-                name=a_element.text.strip(),
-                slug=slug,
-                cover=element.div.img.get('data-src').strip(),
-            ))
-
-        return result
-
-    @CompleteChallenge()
-    def get_latest_updates(self, status=None):
-        return self.get_manga_list('updated_at', status)
-
-    @CompleteChallenge()
-    def get_most_populars(self, status=None):
-        return self.get_manga_list('score', status)
-
-    @CompleteChallenge()
-    @get_data
-    def search(self, term, status=None):
-        r = self.session_get(
-            self.search_url,
-            params={
-                'q': term,
-            },
-            headers={
-                'Accept': 'application/json, text/javascript, */*; q=0.01',
-                'Referer': f'{self.base_url}/',
-                'X-CSRF-TOKEN': self.csrf_token,
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        result = []
-        for item in r.json():
-            result.append(dict(
-                name=item['name'],
-                slug=item['slug'],
-                cover=self.cover_url.format(item['slug']),
-            ))
-
-        return result
diff --git a/komikku/servers/manganelo/__init__.py b/komikku/servers/manganelo/__init__.py
deleted file mode 100644
index 50f12bbe..00000000
--- a/komikku/servers/manganelo/__init__.py
+++ /dev/null
@@ -1,246 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from bs4 import BeautifulSoup
-import requests
-
-from komikku.consts import USER_AGENT
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.servers.utils import get_soup_element_inner_text
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-# NOTE: https://mangakakalot.gg seems to be a clone (same IP)
-# https://www.mangabats.com
-# https://www.natomanga.com
-
-
-class Manganelo(Server):
-    id = 'manganelo'
-    name = 'MangaNato (MangaNelo)'
-    lang = 'en'
-
-    has_cf = True
-    long_strip_genres = ['Webtoons', ]
-
-    base_url = 'https://www.manganato.gg'
-    logo_url = base_url + '/images/favicon.ico'
-    latest_updates_url = base_url + '/manga-list/latest-manga'
-    most_populars_url = base_url + '/manga-list/hot-manga'
-    search_url = base_url + '/home/search/json'
-    manga_url = base_url + '/manga/{0}'
-    chapter_url = base_url + '/manga/{0}/chapter-{1}'
-    api_chapters_url = base_url + '/api/manga/{0}/chapters?limit=-1'
-
-    bypass_cf_url = search_url + '?searchword=blossom'
-
-    def __init__(self):
-        if self.session is None:
-            self.session = requests.Session()
-            self.session.headers.update({'User-Agent': USER_AGENT})
-
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-        ))
-
-        # Name & cover
-        data['name'] = soup.select_one('.manga-info-text h1').text.strip()
-        data['cover'] = soup.select_one('.manga-info-pic img').get('src')
-
-        # Details
-        for li_element in soup.select('ul.manga-info-text li'):
-            try:
-                label, value = li_element.text.split(':', 1)
-            except Exception:
-                continue
-
-            label = label.strip()
-
-            if label.startswith('Author'):
-                data['authors'] = [t.strip() for t in value.split(',') if t.strip() != 'Unknown']
-            elif label.startswith('Genres'):
-                data['genres'] = [t.strip() for t in value.split(',')]
-            elif label.startswith('Status'):
-                status = value.strip().lower()
-                if status == 'completed':
-                    data['status'] = 'complete'
-                elif status == 'ongoing':
-                    data['status'] = 'ongoing'
-
-        # Synopsis
-        data['synopsis'] = get_soup_element_inner_text(soup.select_one('#contentBox'), recursive=False)
-
-        # Chapters
-        r = self.session_get(self.api_chapters_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return data
-
-        resp_data = r.json()
-        if not resp_data.get('success'):
-            return data
-
-        for chapter in reversed(resp_data['data']['chapters']):
-            data['chapters'].append(dict(
-                slug='-'.join(chapter['chapter_slug'].split('-')[1:]),
-                title=chapter['chapter_name'].strip(),
-                num=chapter['chapter_num'] if is_number(chapter.get('chapter_num')) else None,
-                date=convert_date_string(chapter['updated_at'].split('T')[0], format='%Y-%m-%d'),
-            ))
-
-        return data
-
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for img in soup.select('.container-chapter-reader img'):
-            data['pages'].append(dict(
-                slug=None,  # slug can't be used to forge image URL
-                image=img.get('src'),
-            ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Accept': 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5',
-                'Referer': f'{self.base_url}/',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    def get_latest_updates(self):
-        """
-        Returns latest manga list
-        """
-        return self.get_manga_list(orderby='latest')
-
-    def get_most_populars(self):
-        """
-        Returns hot manga list
-        """
-        return self.get_manga_list(orderby='popular')
-
-    def get_manga_list(self, orderby=None):
-        """
-        Returns hot manga list
-        """
-        if orderby == 'popular':
-            url = self.most_populars_url
-        elif orderby == 'latest':
-            url = self.latest_updates_url
-
-        r = self.session_get(url)
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.select('.list-comic-item-wrap'):
-            link_element = element.h3.a
-            link_cover_element = element.a
-            last_chapter_link_element = element.select_one('.list-story-item-wrap-chapter')
-            results.append(dict(
-                name=link_element.get('title').strip(),
-                slug=link_element.get('href').split('/')[-1],
-                cover=link_cover_element.img.get('src'),
-                last_chapter=last_chapter_link_element.text.strip().split()[-1],
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def search(self, term):
-        r = self.session_get(
-            self.search_url,
-            params=dict(searchword=term.lower().replace(' ', '_')),
-            headers={
-                'Referer': f'{self.base_url}/',
-                'X-Requested-With': 'XMLHttpRequest',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        data = r.json()
-
-        results = []
-        for item in data:
-            results.append(dict(
-                slug=item['slug'],
-                name=item['name'],
-                cover=item['thumb'],
-                last_chapter=item['chapterLatest'].split()[-1],
-            ))
-
-        return results
diff --git a/komikku/servers/mangatube/__init__.py b/komikku/servers/mangatube/__init__.py
deleted file mode 100644
index 409d8bab..00000000
--- a/komikku/servers/mangatube/__init__.py
+++ /dev/null
@@ -1,368 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import datetime
-from functools import wraps
-from gettext import gettext as _
-import json
-import logging
-import re
-
-from bs4 import BeautifulSoup
-import requests
-
-from komikku.consts import USER_AGENT
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
-
-logger = logging.getLogger(__name__)
-
-
-def convert_old_slug(slug):
-    # for ex: 4-one-piece => one_piece
-    if slug.split('-')[0].isdigit():
-        return '_'.join(slug.split('-')[1:])
-
-    return slug
-
-
-def get_data(func):
-    @wraps(func)
-    def wrapper(*args, **kwargs):
-        server = args[0]
-        if server.csrf_token:
-            return func(*args, **kwargs)
-
-        r = server.session_get(server.base_url)
-        if r.status_code != 200:
-            return func(*args, **kwargs)
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        for script_element in soup.select('script'):
-            script = script_element.string
-            if not script:
-                continue
-
-            if '_token' in script:
-                # CSRF token
-                for line in script.split('\n'):
-                    line = line.strip()
-                    if match := server.re_csrf_token.search(line):
-                        server.csrf_token = match.group(1)
-                        break
-
-            elif 'window.laravel.route' in script:
-                # Various data: latest updates, genres...
-                for line in script.split('\n'):
-                    line = line.strip()
-                    if line.startswith('window.laravel.route'):
-                        try:
-                            data = json.loads(line[23:-1].replace(',}', '}'))['data']
-                        except Exception:
-                            logger.error('Failed to parse homepage and retrieved data')
-                        else:
-                            server.data = dict(
-                                genres=dict(),
-                                latest_updates=[],
-                            )
-
-                            slugs = []
-                            for item in data['published']:
-                                slug = item['manga']['url'].split('/')[-1]
-                                if slug in slugs:
-                                    continue
-
-                                last_chapter = item['chapters'][0]
-                                number = last_chapter['number']
-                                if last_chapter['subNumber']:
-                                    number = f'{number}.{last_chapter["subNumber"]}'
-
-                                server.data['latest_updates'].append(dict(
-                                    slug=slug,
-                                    name=item['manga']['title'],
-                                    cover=item['manga']['cover'],
-                                    last_chapter=f'{number} - {last_chapter["name"]}',
-                                ))
-                                slugs.append(slug)
-
-                            for genre in data['genre-map']:
-                                server.data['genres'][genre['genre_id']] = genre['genre_name']
-
-                        break
-
-        return func(*args, **kwargs)
-
-    return wrapper
-
-
-class Mangatube(Server):
-    id = 'mangatube'
-    name = 'Manga-Tube'
-    lang = 'de'
-    is_nsfw = True
-
-    has_captcha = True  # Custom captcha challange
-
-    base_url = 'https://manga-tube.me'
-    search_url = base_url + '/search'
-    manga_url = base_url + '/series/{0}'
-    chapter_url = base_url + '/series/{0}/read/{1}/1'
-    image_url = 'https://a.mtcdn.org/m/{0}/{1}/{2}'
-    api_url = base_url + '/api'
-    api_search_url = api_url + '/manga/search'
-    api_manga_url = api_url + '/manga/{0}'
-    api_chapters_url = api_url + '/manga/{0}/chapters'
-    api_chapter_url = api_url + '/manga/{0}/chapter/{1}'
-
-    filters = [
-        {
-            'key': 'type',
-            'type': 'select',
-            'name': _('Type'),
-            'description': _('Type of Serie'),
-            'value_type': 'single',
-            'default': None,
-            'options': [
-                {'key': '-1', 'name': _('All')},
-                {'key': '0', 'name': _('Manga')},
-                {'key': '1', 'name': _('Manhwa')},
-                {'key': '2', 'name': _('Manhua')},
-                {'key': '3', 'name': _('Webtoon')},
-                {'key': '4', 'name': _('Comic')},
-                {'key': '5', 'name': _('Oneshot')},
-                {'key': '6', 'name': _('Light Novel')},
-            ]
-        },
-        {
-            'key': 'mature',
-            'type': 'select',
-            'name': _('Age Rating'),
-            'description': _('Maturity'),
-            'value_type': 'single',
-            'default': None,
-            'options': [
-                {'key': '-1', 'name': _('Without')},
-                {'key': '1', 'name': _('16+')},
-                {'key': '2', 'name': _('18+')},
-            ]
-        },
-    ]
-
-    re_csrf_token = re.compile(r'.*\"_token\": \"([a-zA-Z0-9]*)\".*')
-
-    def __init__(self):
-        # Data retrieved by parsing JS code in home page (genres, latest updates)
-        self.csrf_token = None
-        self.data = None
-
-        if self.session is None:
-            self.session = requests.Session()
-            self.session.headers.update({'User-Agent': USER_AGENT})
-
-    @CompleteChallenge()
-    @get_data
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data using API
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        slug = convert_old_slug(initial_data['slug'])
-
-        r = self.session_get(self.api_manga_url.format(slug), headers={
-            'Content-Type': 'application/json, text/plain, */*',
-            'Referer': self.manga_url.format(initial_data['slug']),
-            'Use-Parameter': 'manga_slug',
-            'X-Csrf-TOKEN': self.csrf_token,
-            'X-Requested-With': 'XMLHttpRequest',
-        })
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/plain':
-            return None
-
-        resp_data = r.json()['data']['manga']
-
-        data = dict(
-            slug=slug,
-            name=resp_data['title'],
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=resp_data['description'],
-            chapters=[],
-            server_id=self.id,
-            cover=resp_data['cover'],
-        )
-
-        # Details
-        for artist in resp_data['artist']:
-            data['authors'].append(artist['name'])
-        for author in resp_data['author']:
-            if author['name'] not in data['authors']:
-                data['authors'].append(author['name'])
-
-        if self.data and self.data.get('genres'):
-            for genre_id in resp_data['genre']:
-                data['genres'].append(self.data['genres'][genre_id])
-
-        if resp_data['status'] == 0:
-            data['status'] = 'ongoing'
-        elif resp_data['status'] == 1:
-            data['status'] = 'hiatus'
-        elif resp_data['status'] == 3:
-            data['status'] = 'suspended'
-        elif resp_data['status'] == 4:
-            data['status'] = 'complete'
-
-        # Chapters
-        r = self.session_get(self.api_chapters_url.format(initial_data['slug']), headers={
-            'Content-Type': 'application/json, text/plain, */*',
-            'Include-Teams': 'true',
-            'Referer': self.manga_url.format(initial_data['slug']),
-            'Use-Parameter': 'manga_slug',
-            'X-Csrf-TOKEN': self.csrf_token,
-            'X-Requested-With': 'XMLHttpRequest',
-        })
-        if r.status_code != 200:
-            return data
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/plain':
-            return None
-
-        chapters = r.json()['data']['chapters']
-
-        for chapter in reversed(chapters):
-            number = chapter['number']
-            if chapter['subNumber']:
-                number = f'{number}.{chapter["subNumber"]}'
-
-            title = f'[Band {chapter["volume"]}] Kapitel {number} - {chapter["name"]}'
-
-            data['chapters'].append(dict(
-                slug=chapter['id'],
-                title=title,
-                num=number,
-                num_volume=chapter['volume'],
-                date=convert_date_string(chapter['publishedAt'].split(' ')[0], format='%Y-%m-%d'),
-                scanlators=[team['name'] for team in chapter['teams']],
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    @get_data
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data using API
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.api_chapter_url.format(manga_slug, chapter_slug), headers={
-            'Content-Type': 'application/json, text/plain, */*',
-            'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-            'Use-Parameter': 'manga_slug',
-            'X-Csrf-TOKEN': self.csrf_token,
-            'X-Requested-With': 'XMLHttpRequest',
-        })
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/plain':
-            return None
-
-        data = dict(
-            pages=[],
-        )
-        for index, page in enumerate(r.json()['data']['chapter']['pages']):
-            data['pages'].append(dict(
-                slug=None,
-                image=page['url'],
-                index=index + 1,
-            ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(page['image'], headers={
-            'Referer': f'{self.base_url}/',
-        })
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name='{0:03d}.{1}'.format(page['index'], mime_type.split('/')[-1]),
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    @CompleteChallenge()
-    @get_data
-    def get_latest_updates(self, **kwargs):
-        return self.data['latest_updates'] if self.data else None
-
-    @CompleteChallenge()
-    def get_most_populars(self, type=None, mature=None):
-        return self.search(populars=True, type=type, mature=mature)
-
-    @CompleteChallenge()
-    def search(self, term=None, populars=False, type=None, mature=None):
-        params = {
-            'year[]': [1970, datetime.date.today().year],
-            'type': type if type is not None else -1,
-            'status': -1,
-            'mature': mature if mature is not None else -1,
-            'query': term if term is not None else '',
-            'rating[]': [1, 5],
-            'page': 1,
-            'sort': 'desc' if populars else 'asc',
-            'order': 'rating' if populars else 'name',
-        }
-
-        r = self.session_get(self.api_search_url, params=params, headers={
-            'Content-Type': 'application/json, text/plain, */*',
-            'Referer': self.search_url,
-            'X-Requested-With': 'XMLHttpRequest',
-        })
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/plain':
-            return None
-
-        resp_data = r.json()
-
-        results = []
-        for item in resp_data['data']:
-            results.append(dict(
-                slug=item['slug'],
-                name=item['title'],
-                cover=item['cover'],
-            ))
-
-        return results
diff --git a/komikku/servers/mangaworld/__init__.py b/komikku/servers/mangaworld/__init__.py
deleted file mode 100644
index 3f45299e..00000000
--- a/komikku/servers/mangaworld/__init__.py
+++ /dev/null
@@ -1,251 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from functools import wraps
-from gettext import gettext as _
-
-from bs4 import BeautifulSoup
-import requests
-
-from komikku.consts import USER_AGENT
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import get_page_html
-
-
-def get_mwcookie(func):
-    @wraps(func)
-    def wrapper(*args, **kwargs):
-        server = args[0]
-
-        found = False
-        for name, _value in server.session.cookies.items():
-            if name == 'MWCookie':
-                found = True
-                break
-
-        if not found:
-            _html, cookies = get_page_html(server.base_url, user_agent=USER_AGENT, with_cookies=True)
-
-            for cookie in cookies:
-                if cookie.name == 'MWCookie':
-                    server.session.cookies.set_cookie(cookie)
-                    break
-
-        return func(*args, **kwargs)
-
-    return wrapper
-
-
-class Mangaworld(Server):
-    id = 'mangaworld'
-    name = 'MangaWorld'
-    lang = 'it'
-    is_nsfw = True
-
-    base_url = 'https://www.mangaworld.cx'
-    logo_url = base_url + '/public/assets/seo/favicon-32x32.png'
-    search_url = base_url + '/archive'
-    manga_url = base_url + '/manga/{0}'
-    chapter_url = base_url + '/manga/{0}/read/{1}/1?style=list'
-
-    filters = [
-        {
-            'key': 'types',
-            'type': 'select',
-            'name': _('Type'),
-            'description': _('Filter by Types'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'manga', 'name': _('Manga'), 'default': False},
-                {'key': 'manhua', 'name': _('Manhua'), 'default': False},
-                {'key': 'manhwa', 'name': _('Manhwa'), 'default': False},
-                {'key': 'oneshot', 'name': _('Oneshot'), 'default': False},
-                {'key': 'thai', 'name': 'Thai', 'default': False},
-                {'key': 'vietnamese', 'name': 'Vietnamita', 'default': False},
-            ],
-        },
-        {
-            'key': 'statuses',
-            'type': 'select',
-            'name': _('Status'),
-            'description': _('Filter by Statuses'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'ongoing', 'name': _('Ongoing'), 'default': False},
-                {'key': 'completed', 'name': _('Complete'), 'default': False},
-                {'key': 'dropped', 'name': _('Suspended'), 'default': False},
-                {'key': 'paused', 'name': _('Hiatus'), 'default': False},
-                {'key': 'canceled', 'name': _('Canceled'), 'default': False},
-            ],
-        },
-    ]
-
-    def __init__(self):
-        if self.session is None:
-            self.session = requests.Session()
-            self.session.headers.update({
-                'User-Agent': USER_AGENT,
-            })
-
-    @get_mwcookie
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        ))
-
-        info_element = soup.find(class_='comic-info')
-
-        # Name & cover
-        data['name'] = info_element.find('h1', class_='name').text.strip()
-        data['cover'] = info_element.find(class_='thumb').img.get('src')
-
-        # Details
-        details_element = info_element.find(class_='meta-data')
-        for element in details_element.find_all('div'):
-            label = element.span.text.strip()
-            if label.startswith('Generi'):
-                for a_element in element.find_all('a'):
-                    data['genres'].append(a_element.text.strip())
-            elif label.startswith(('Autore', 'Artista', 'Autori', 'Artisti')):
-                for a_element in element.find_all('a'):
-                    author = a_element.text.strip()
-                    if author not in data['authors']:
-                        data['authors'].append(author)
-            elif label.startswith('Stato'):
-                status = element.a.text.strip().lower()
-                if status == 'in corso':
-                    data['status'] = 'ongoing'
-                elif status == 'finito':
-                    data['status'] = 'complete'
-                elif status in ('cancellato', 'droppato'):
-                    data['status'] = 'suspended'
-                elif status == 'in pausa':
-                    data['status'] = 'hiatus'
-
-        # Synopsis
-        data['synopsis'] = soup.select_one('.comic-description div:nth-child(2)').text.strip()
-
-        # Chapters
-        for element in reversed(soup.select('.chapters-wrapper .chapter')):
-            title = element.span.text.strip()
-            num = title.split(' ')[-1]  # Capitolo XXX[.X]
-
-            data['chapters'].append(dict(
-                slug=element.a.get('href').split('/')[-1],
-                title=title,
-                num=num if is_number(num) else None,
-                date=convert_date_string(element.i.text.strip()),
-            ))
-
-        return data
-
-    @get_mwcookie
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for img_element in soup.find_all('img', class_='page-image'):
-            data['pages'].append(dict(
-                slug=None,
-                image=img_element.get('src'),
-            ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(page['image'])
-        if r.status_code != 200:
-            return None
-
-        buffer = r.content
-        mime_type = get_buffer_mime_type(buffer)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=buffer,
-            mime_type=mime_type,
-            name=page['image'].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    def get_latest_updates(self, types=None, statuses=None):
-        return self.search('', types, statuses, orderby='latest')
-
-    def get_most_populars(self, types=None, statuses=None):
-        return self.search('', types, statuses, orderby='populars')
-
-    @get_mwcookie
-    def search(self, term, types=None, statuses=None, orderby=None):
-        if orderby:
-            params = {
-                'sort': 'most_read' if orderby == 'populars' else 'newest',
-            }
-        else:
-            params = {
-                'keyword': term,
-            }
-        if types:
-            params['type'] = types
-        if statuses:
-            params['status'] = statuses
-
-        r = self.session_get(self.search_url, params=params)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for a_element in soup.select('.comics-grid .entry > a'):
-            results.append(dict(
-                name=a_element.get('title').strip(),
-                slug='/'.join(a_element.get('href').split('/')[-2:]),
-                cover=a_element.img.get('src'),
-            ))
-
-        return results
diff --git a/komikku/servers/manhwahentai/__init__.py b/komikku/servers/manhwahentai/__init__.py
deleted file mode 100644
index 55b6a70d..00000000
--- a/komikku/servers/manhwahentai/__init__.py
+++ /dev/null
@@ -1,83 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import json
-
-from bs4 import BeautifulSoup
-
-from komikku.servers.multi.madara import Madara
-from komikku.webview import CompleteChallenge
-from komikku.utils import get_buffer_mime_type
-
-
-class Manhwahentai(Madara):
-    id = 'manhwahentai'
-    name = 'Manhwa Hentai'
-    lang = 'en'
-    is_nsfw_only = True
-    status = 'disabled'
-
-    date_format = '%d %B %Y'
-    series_name = 'pornhwa'
-
-    base_url = 'https://manhwahentai.to'
-    chapters_url = base_url + '/pornhwa/{0}/ajax/chapters/'
-
-    chapters_list_selector = '.manga-post-chapters'
-    details_name_selector = '.post-title p.h1'
-    details_authors_selector = '.post-tax-wp-manga-artist .post-tags .tag-name'
-    details_genres_selector = '.post-tax-wp-manga-category .post-tags .tag-name'
-    details_status_selector = None
-    details_synopsis_selector = '.summary_content .post-meta:-soup-contains("Description") .post-tag .tag-name'
-    results_selector = '.manga'
-    result_name_slug_selector = '.post-title a'
-    result_cover_selector = '.item-thumb img'
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(
-            self.chapter_url.format(manga_slug, chapter_slug),
-            headers={
-                'Referer': self.manga_url.format(manga_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-
-        # Pages images are loaded via javascript
-        for script_element in soup.find_all('script'):
-            script = script_element.string
-            if script is None or script_element.get('id') != 'chapter_preloaded_images':
-                continue
-
-            for line in script.split('\n'):
-                if 'chapter_preloaded_images' not in line:
-                    continue
-                line = line.strip()
-                json_data = line.split('=')[-1]
-                if json_data[-2] == ',':
-                    json_data = json_data[:-2] + ']'
-                for image in json.loads(json_data):
-                    data['pages'].append(dict(
-                        slug=None,
-                        image=image['src'],
-                    ))
-                break
-
-        return data
diff --git a/komikku/servers/multi/heancms/__init__.py b/komikku/servers/multi/heancms/__init__.py
index e7b5836f..295301c9 100644
--- a/komikku/servers/multi/heancms/__init__.py
+++ b/komikku/servers/multi/heancms/__init__.py
@@ -28,7 +28,6 @@ from komikku.servers.utils import convert_date_string
 from komikku.servers.utils import parse_nextjs_hydration
 from komikku.utils import get_buffer_mime_type
 from komikku.utils import get_response_elapsed
-from komikku.webview import CompleteChallenge
 
 logger = logging.getLogger('komikku.servers.multi.heancms')
 
@@ -57,7 +56,6 @@ class HeanCMS(Server):
             self.session = requests.Session()
             self.session.headers.update({'User-Agent': USER_AGENT})
 
-    @CompleteChallenge()
     def get_manga_data(self, initial_data):
         """
         Returns manga data via API request
@@ -112,7 +110,6 @@ class HeanCMS(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
         """
         Returns manga chapter data by scraping chapter HTML page content
@@ -201,7 +198,6 @@ class HeanCMS(Server):
 
         return list(reversed(chapters))
 
-    @CompleteChallenge()
     def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
         """
         Returns chapter page scan (image) content
@@ -231,7 +227,6 @@ class HeanCMS(Server):
         """
         return self.manga_url.format(slug)
 
-    @CompleteChallenge()
     def get_latest_updates(self):
         """
         Returns latest updates
@@ -276,13 +271,11 @@ class HeanCMS(Server):
 
         return results
 
-    @CompleteChallenge()
     def get_most_populars(self):
         """
         Returns most popular mangas
         """
         return self.get_manga_list(orderby='popular')
 
-    @CompleteChallenge()
     def search(self, term):
         return self.get_manga_list(term=term)
diff --git a/komikku/servers/multi/keyoapp/__init__.py b/komikku/servers/multi/keyoapp/__init__.py
index 43f190e9..9e9edd6a 100644
--- a/komikku/servers/multi/keyoapp/__init__.py
+++ b/komikku/servers/multi/keyoapp/__init__.py
@@ -18,7 +18,6 @@ from komikku.servers import Server
 from komikku.servers.utils import convert_date_string
 from komikku.utils import get_buffer_mime_type
 from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
 
 logger = logging.getLogger(__name__)
 
@@ -58,7 +57,6 @@ class Keyoapp(Server):
             self.session = requests.Session()
             self.session.headers.update({'User-Agent': USER_AGENT})
 
-    @CompleteChallenge()
     def get_manga_data(self, initial_data):
         """
         Returns manga data by scraping manga HTML page content
@@ -148,7 +146,6 @@ class Keyoapp(Server):
 
         return chapters
 
-    @CompleteChallenge()
     def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
         """
         Returns manga chapter data by scraping chapter HTML page content
@@ -215,7 +212,6 @@ class Keyoapp(Server):
         """
         return self.manga_url.format(slug)
 
-    @CompleteChallenge()
     def get_latest_updates(self):
         """
         Returns latest updates
@@ -247,7 +243,6 @@ class Keyoapp(Server):
 
         return results
 
-    @CompleteChallenge()
     def get_most_populars(self):
         """
         Returns most popular manga
@@ -274,7 +269,6 @@ class Keyoapp(Server):
 
         return results
 
-    @CompleteChallenge()
     def search(self, term):
         r = self.session_get(
             self.search_url,
diff --git a/komikku/servers/multi/madara/__init__.py b/komikku/servers/multi/madara/__init__.py
index 5e0b0405..84594d58 100644
--- a/komikku/servers/multi/madara/__init__.py
+++ b/komikku/servers/multi/madara/__init__.py
@@ -51,7 +51,6 @@ from komikku.servers.utils import convert_date_string
 from komikku.servers.utils import get_soup_element_inner_text
 from komikku.servers.utils import remove_emoji_from_string
 from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
 
 logger = logging.getLogger('komikku.servers.madara')
 
@@ -113,7 +112,6 @@ class Madara(Server):
 
         return None, None
 
-    @CompleteChallenge()
     def get_manga_data(self, initial_data):
         """
         Returns manga data by scraping manga HTML page content
@@ -272,7 +270,6 @@ class Madara(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
         """
         Returns manga chapter data by scraping chapter HTML page content
@@ -314,7 +311,6 @@ class Madara(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
         """
         Returns chapter page scan (image) content
@@ -356,7 +352,6 @@ class Madara(Server):
         """
         return self.search('', orderby='populars')
 
-    @CompleteChallenge()
     def search(self, term, orderby=None):
         data = {
             'action': 'madara_load_more',
@@ -444,21 +439,18 @@ class Madara2(Madara):
         if Settings.instance:
             self.filters[0]['default'] = Settings.get_default().nsfw_content
 
-    @CompleteChallenge()
     def get_latest_updates(self, nsfw):
         """
         Returns list of latest updates manga
         """
         return self.search(None, nsfw=nsfw, orderby='latest')
 
-    @CompleteChallenge()
     def get_most_populars(self, nsfw):
         """
         Returns list of most viewed manga
         """
         return self.search(None, nsfw=nsfw, orderby='populars')
 
-    @CompleteChallenge()
     def search(self, term, nsfw, orderby=None):
         params = {
             's': term or '',
diff --git a/komikku/servers/multi/madtheme/__init__.py b/komikku/servers/multi/madtheme/__init__.py
index cba01369..d666a8de 100644
--- a/komikku/servers/multi/madtheme/__init__.py
+++ b/komikku/servers/multi/madtheme/__init__.py
@@ -13,7 +13,6 @@ from komikku.consts import USER_AGENT
 from komikku.servers import Server
 from komikku.servers.utils import convert_date_string
 from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
 
 logger = logging.getLogger(__name__)
 
@@ -68,7 +67,6 @@ class Madtheme(Server):
             self.session = requests.Session()
             self.session.headers.update({'User-Agent': USER_AGENT})
 
-    @CompleteChallenge()
     def get_manga_data(self, initial_data):
         """
         Returns manga data by scraping manga HTML page content
@@ -118,7 +116,6 @@ class Madtheme(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
         """
         Returns manga chapter data by scraping chapter HTML page content
@@ -177,7 +174,6 @@ class Madtheme(Server):
                 'date': convert_date_string(date_element.text.strip(), format=self.date_format, languages=[self.lang]),
             })
 
-    @CompleteChallenge()
     def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
         """
         Returns chapter page scan (image) content
@@ -285,20 +281,17 @@ class Madtheme(Server):
 
         return results
 
-    @CompleteChallenge()
     def get_latest_updates(self, status=None):
         """
         Returns list of latest manga
         """
         return self.get_manga_list(status=status, orderby='latest')
 
-    @CompleteChallenge()
     def get_most_populars(self, status=None):
         """
         Returns list of popular manga
         """
         return self.get_manga_list(status=status, orderby='popular')
 
-    @CompleteChallenge()
     def search(self, term, status=None):
         return self.get_manga_list(term=term, status=status)
diff --git a/komikku/servers/multi/manga_stream/__init__.py b/komikku/servers/multi/manga_stream/__init__.py
index 83a67152..c07332e6 100644
--- a/komikku/servers/multi/manga_stream/__init__.py
+++ b/komikku/servers/multi/manga_stream/__init__.py
@@ -43,7 +43,6 @@ from komikku.servers import Server
 from komikku.servers.utils import convert_date_string
 from komikku.servers.utils import get_soup_element_inner_text
 from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
 
 
 class MangaStream(Server):
@@ -107,7 +106,6 @@ class MangaStream(Server):
             self.session = requests.Session()
             self.session.headers.update({'User-Agent': USER_AGENT})
 
-    @CompleteChallenge()
     def get_manga_data(self, initial_data):
         """
         Returns manga data by scraping manga HTML page content
@@ -272,7 +270,6 @@ class MangaStream(Server):
 
         return chapters
 
-    @CompleteChallenge()
     def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
         """
         Returns manga chapter data by scraping chapter HTML page content
@@ -335,7 +332,6 @@ class MangaStream(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
         """
         Returns chapter page scan (image) content
@@ -402,14 +398,11 @@ class MangaStream(Server):
 
         return results
 
-    @CompleteChallenge()
     def get_latest_updates(self, type):
         return self.get_manga_list(type=type, orderby='update')
 
-    @CompleteChallenge()
     def get_most_populars(self, type):
         return self.get_manga_list(type=type, orderby='popular')
 
-    @CompleteChallenge()
     def search(self, term, type):
         return self.get_manga_list(title=term, type=type)
diff --git a/komikku/servers/multi/paprika/__init__.py b/komikku/servers/multi/paprika/__init__.py
index a414db6d..ad2b6c08 100644
--- a/komikku/servers/multi/paprika/__init__.py
+++ b/komikku/servers/multi/paprika/__init__.py
@@ -12,7 +12,6 @@ from komikku.consts import USER_AGENT
 from komikku.servers import Server
 from komikku.utils import get_buffer_mime_type
 from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
 
 
 class Paprika(Server):
@@ -42,7 +41,6 @@ class Paprika(Server):
         if self.chapter_url is None:
             self.chapter_url = self.base_url + '/chapter/{0}'
 
-    @CompleteChallenge()
     def get_manga_data(self, initial_data):
         """
         Returns manga data by scraping manga HTML page content
@@ -129,7 +127,6 @@ class Paprika(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
         r = self.session_get(self.chapter_url.format(chapter_slug))
         if r.status_code != 200:
@@ -148,7 +145,6 @@ class Paprika(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
         """
         Returns chapter page scan (image) content
@@ -178,7 +174,6 @@ class Paprika(Server):
         """
         return self.manga_url.format(slug)
 
-    @CompleteChallenge()
     def get_latest_updates(self):
         r = self.session_get(self.latest_updates_url)
         if r.status_code != 200:
@@ -186,7 +181,6 @@ class Paprika(Server):
 
         return self.parse_manga_list(r.text)
 
-    @CompleteChallenge()
     def get_most_populars(self):
         r = self.session_get(self.most_populars_url)
         if r.status_code != 200:
@@ -209,7 +203,6 @@ class Paprika(Server):
 
         return results
 
-    @CompleteChallenge()
     def search(self, term):
         r = self.session_get(
             self.search_url,
diff --git a/komikku/servers/multi/peachscan/__init__.py b/komikku/servers/multi/peachscan/__init__.py
index c5fc07ca..e3454c7b 100644
--- a/komikku/servers/multi/peachscan/__init__.py
+++ b/komikku/servers/multi/peachscan/__init__.py
@@ -25,7 +25,6 @@ from komikku.servers import Server
 from komikku.servers.utils import convert_date_string
 from komikku.servers.utils import get_soup_element_inner_text
 from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
 
 RE_ZIP_IMAGES = re.compile(r'base64,([a-zA-Z0-9+=\/\n]*)')
 
@@ -59,7 +58,6 @@ class Peachscan(Server):
         if self.image_url is None:
             self.image_url = self.base_url + '{0}#page'
 
-    @CompleteChallenge()
     def get_manga_data(self, initial_data):
         """
         Returns manga data by scraping manga HTML page content
@@ -141,7 +139,6 @@ class Peachscan(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
         r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
         if r.status_code != 200:
@@ -178,7 +175,6 @@ class Peachscan(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
         """
         Returns chapter page scan (image) content
@@ -256,7 +252,6 @@ class Peachscan(Server):
         """
         return self.manga_url.format(slug)
 
-    @CompleteChallenge()
     def get_latest_updates(self):
         r = self.session_get(self.latest_updates_url)
         if r.status_code != 200:
@@ -277,7 +272,6 @@ class Peachscan(Server):
 
         return results
 
-    @CompleteChallenge()
     def get_most_populars(self):
         r = self.session_get(self.most_populars_url)
         if r.status_code != 200:
@@ -298,7 +292,6 @@ class Peachscan(Server):
 
         return results
 
-    @CompleteChallenge()
     def search(self, term):
         r = self.session_get(
             self.search_url,
diff --git a/komikku/servers/multi/wpcomics/__init__.py b/komikku/servers/multi/wpcomics/__init__.py
index 95ce00e7..14c88c39 100644
--- a/komikku/servers/multi/wpcomics/__init__.py
+++ b/komikku/servers/multi/wpcomics/__init__.py
@@ -23,7 +23,6 @@ from komikku.servers.utils import get_soup_element_inner_text
 from komikku.utils import get_buffer_mime_type
 from komikku.utils import is_number
 from komikku.utils import get_response_elapsed
-from komikku.webview import CompleteChallenge
 
 # WPComics Wordpress theme
 
@@ -65,7 +64,6 @@ class WPComics(Server):
                 'User-Agent': USER_AGENT,
             }
 
-    @CompleteChallenge()
     def get_manga_data(self, initial_data):
         """
         Returns comic data by scraping manga HTML page content
@@ -129,7 +127,6 @@ class WPComics(Server):
 
         return data
 
-    @CompleteChallenge()
     def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
         """
         Returns comic chapter data
@@ -236,7 +233,6 @@ class WPComics(Server):
 
         return list(reversed(chapters))
 
-    @CompleteChallenge()
     def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
         """
         Returns chapter page scan (image) content
@@ -333,14 +329,11 @@ class WPComics(Server):
 
         return results
 
-    @CompleteChallenge()
     def get_latest_updates(self):
         return self.get_manga_list(orderby='latest')
 
-    @CompleteChallenge()
     def get_most_populars(self):
         return self.get_manga_list(orderby='populars')
 
-    @CompleteChallenge()
     def search(self, term):
         return self.get_manga_list(term=term)
diff --git a/komikku/servers/nhentai/__init__.py b/komikku/servers/nhentai/__init__.py
deleted file mode 100644
index 58cf9728..00000000
--- a/komikku/servers/nhentai/__init__.py
+++ /dev/null
@@ -1,202 +0,0 @@
-# SPDX-FileCopyrightText: 2020-2024 Liliana Prikler
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Liliana Prikler <liliana.prikler@gmail.com>
-
-from bs4 import BeautifulSoup
-import json
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
-
-IMAGES_EXTS = {'g': 'gif', 'j': 'jpg', 'p': 'png', 'w': 'webp'}
-
-# Mirrors
-# https://nhentai.to
-# https://nhentai.xxx
-
-
-class Nhentai(Server):
-    id = 'nhentai'
-    name = 'NHentai'
-    lang = 'en'
-    lang_code = 'english'
-    is_nsfw_only = True
-
-    has_cf = True
-
-    base_url = 'https://nhentai.net'
-    search_url = base_url + '/search'
-    manga_url = base_url + '/g/{0}'
-    page_image_url = 'https://i.nhentai.net/galleries/{0}/{1}'
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        data = initial_data.copy()
-        data.update({
-            'authors': [],
-            'scanlators': [],  # Not available
-            'genres': [],
-            'status': None,    # Not available
-            'synopsis': None,  # Not available
-            'chapters': [],
-            'server_id': self.id,
-        })
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data['name'] = soup.find('meta', property='og:title')['content']
-        data['cover'] = 'https:' + soup.find('meta', property='og:image')['content']
-
-        # Genres & Artists + chapter date
-        chapter_date = None
-        info = soup.select_one('#info')
-        for tag_container in info.select('#tags .tag-container'):
-            category = tag_container.text.split(':')[0].strip()
-
-            if category == 'Uploaded':
-                if time_element := tag_container.select_one('time'):
-                    chapter_date = time_element.get('datetime').split('T')[0]
-
-            for tag in tag_container.select('.tag'):
-                clean_tag = tag.select_one('span.name').text.strip()
-                if category in ['Artists', 'Groups', ]:
-                    data['authors'].append(clean_tag)
-                if category in ['Tags', ]:
-                    data['genres'].append(clean_tag)
-
-        # Single chapter
-        data['chapters'].append({
-            'slug': data['cover'].rstrip('/').split('/')[-2],
-            'title': data['name'],
-            'date': convert_date_string(chapter_date, '%Y-%m-%d') if chapter_date else None,
-        })
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.manga_url.format(manga_slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        pages = []
-        for script_element in soup.find_all('script'):
-            script = script_element.string
-            if not script or not script.strip().startswith('window._gallery'):
-                continue
-
-            info = json.loads(script.strip().split('\n')[0][30:-3].replace('\\u0022', '"').replace('\\u005C', '\\'))
-            if not info.get('images') or not info['images'].get('pages'):
-                break
-
-            for index, page in enumerate(info['images']['pages']):
-                num = index + 1
-                extension = IMAGES_EXTS[page['t']]
-                page = {
-                    'image': None,
-                    'slug': f'{num}.{extension}',
-                }
-                pages.append(page)
-
-        return {'pages': pages}
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        assert chapter_slug is not None
-        r = self.session_get(self.page_image_url.format(chapter_slug, page['slug']))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return {
-            'buffer': r.content,
-            'mime_type': mime_type,
-            'name': page['slug'],
-        }
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    def _search_common(self, params):
-        r = self.session_get(self.search_url, params=params)
-
-        if r.status_code == 200:
-            try:
-                results = []
-                soup = BeautifulSoup(r.text, 'lxml')
-                elements = soup.find_all('div', class_='gallery')
-
-                for element in elements:
-                    a_element = element.find('a', class_='cover')
-                    caption_element = element.find('div', class_='caption')
-                    results.append({
-                        'slug': a_element.get('href').rstrip('/').split('/')[-1],
-                        'name': caption_element.text.strip(),
-                        'cover': 'https:' + a_element.img.get('data-src'),
-                    })
-            except Exception:
-                return None
-            else:
-                return results
-
-        return None
-
-    @CompleteChallenge()
-    def get_most_populars(self):
-        """
-        Returns most popular mangas (bayesian rating)
-        """
-        return self._search_common({'q': 'language:' + self.lang_code, 'sort': 'popular'})
-
-    @CompleteChallenge()
-    def search(self, term):
-        term = term + ' language:' + self.lang_code
-        return self._search_common({'q': term})
-
-
-class Nhentai_chinese(Nhentai):
-    id = 'nhentai_chinese'
-    lang = 'zh_Hans'
-    lang_code = 'chinese'
-
-
-class Nhentai_japanese(Nhentai):
-    id = 'nhentai_japanese'
-    lang = 'ja'
-    lang_code = 'japanese'
diff --git a/komikku/servers/phenixscans/__init__.py b/komikku/servers/phenixscans/__init__.py
deleted file mode 100644
index a3f46ab5..00000000
--- a/komikku/servers/phenixscans/__init__.py
+++ /dev/null
@@ -1,204 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from gettext import gettext as _
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-
-class Phenixscans(Server):
-    id = 'phenixscans'
-    name = 'Phenix Scans'
-    lang = 'fr'
-
-    has_cf = True
-
-    base_url = 'https://phenix-scans.co'
-    logo_url = base_url + '/logo.png'
-    api_base_url = 'https://api.phenix-scans.co/api'
-    api_list_url = api_base_url + '/front/manga'
-    api_search_url = api_base_url + '/front/manga/search'
-    api_manga_url = api_base_url + '/front/manga/{0}'
-    api_chapter_url = api_base_url + '/front/manga/{0}/chapter/{1}'
-    manga_url = base_url + '/manga/{0}'
-    media_url = 'https://api.phenix-scans.co' + '/{0}'
-
-    filters = [
-        {
-            'key': 'type',
-            'type': 'select',
-            'name': _('Type'),
-            'description': _('Filter by Type'),
-            'value_type': 'single',
-            'default': '',
-            'options': [
-                {'key': '', 'name': _('All')},
-                {'key': 'Manga', 'name': _('Manga')},
-                {'key': 'Manhwa', 'name': _('Manhwa')},
-                {'key': 'Manhua', 'name': _('Manhua')},
-            ],
-        },
-    ]
-    long_strip_genres = ['Manhua', 'Manhwa']
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data using API
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        r = self.session_get(self.api_manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        resp_data = r.json()
-
-        data = initial_data.copy()
-        data.update(dict(
-            name=resp_data['manga']['title'],
-            authors=[],     # Not available
-            scanlators=[],  # Not available
-            genres=[genre['name'] for genre in resp_data['manga']['genres']],
-            status=None,
-            synopsis=resp_data['manga'].get('synopsis'),
-            chapters=[],
-            server_id=self.id,
-            cover=self.media_url.format(resp_data['manga']['coverImage']),
-        ))
-
-        if resp_data['manga'].get('type'):
-            data['genres'].append(resp_data['manga']['type'])
-
-        if resp_data['manga']['status'] == 'Ongoing':
-            data['status'] = 'ongoing'
-        elif resp_data['manga']['status'] == 'Completed':
-            data['status'] = 'complete'
-        elif resp_data['manga']['status'] == 'Hiatus':
-            data['status'] = 'hiatus'
-
-        # Chapters
-        for chapter in reversed(resp_data['chapters']):
-            data['chapters'].append({
-                'slug': chapter['number'],
-                'title': f'Chapitre {chapter["number"]}',
-                'num': chapter['number'] if is_number(chapter['number']) else None,
-                'date': convert_date_string(chapter['createdAt'].split('T')[0], '%Y-%m-%d'),
-            })
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        r = self.session_get(self.api_chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        resp_data = r.json()
-
-        data = dict(
-            pages=[],
-        )
-        for index, image in enumerate(resp_data['chapter']['images']):
-            data['pages'].append({
-                'slug': None,
-                'image': image,
-                'index': index + 1,
-            })
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            self.media_url.format(page['image']),
-            headers={
-                'Referer': f'{self.base_url}/',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name='{0:03d}.{1}'.format(page['index'], mime_type.split('/')[-1]),
-        )
-
-    def get_manga_list(self, orderby=None, type=None):
-        params = {
-            'page': 1,
-            'limit': 18,
-            'sort': orderby,
-        }
-        if type:
-            params['type'] = type
-
-        r = self.session_get(
-            self.api_list_url,
-            params=params
-        )
-        if r.status_code != 200:
-            return None
-
-        data = r.json()
-
-        result = []
-        for item in data['mangas']:
-            result.append(dict(
-                name=item['title'],
-                slug=item['slug'],
-                cover=self.media_url.format(item['coverImage']),
-            ))
-
-        return result
-
-    @CompleteChallenge()
-    def get_latest_updates(self, type=None):
-        return self.get_manga_list(orderby='updatedAt', type=type)
-
-    def get_manga_url(self, slug, url):
-        return self.manga_url.format(slug)
-
-    @CompleteChallenge()
-    def get_most_populars(self, type=None):
-        return self.get_manga_list(orderby='rating', type=type)
-
-    @CompleteChallenge()
-    def search(self, term, type=None):
-        # Filtering by type is not available in search endpoint
-        r = self.session_get(
-            self.api_search_url,
-            params={
-                'query': term,
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        data = r.json()
-
-        result = []
-        for item in data['mangas']:
-            result.append(dict(
-                name=item['title'],
-                slug=item['slug'],
-                cover=self.media_url.format(item['coverImage']),
-            ))
-
-        return result
diff --git a/komikku/servers/raijinscan/__init__.py b/komikku/servers/raijinscan/__init__.py
deleted file mode 100644
index 171c5909..00000000
--- a/komikku/servers/raijinscan/__init__.py
+++ /dev/null
@@ -1,304 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import base64
-from gettext import gettext as _
-import json
-import logging
-import re
-
-from bs4 import BeautifulSoup
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-logger = logging.getLogger(__name__)
-
-
-class Raijinscan(Server):
-    id = 'raijinscan'
-    name = 'Raijin Scan'
-    lang = 'fr'
-    has_cf = True
-
-    base_url = 'https://raijin-scans.fr'
-    logo_url = base_url + '/wp-content/uploads/2025/05/cropped-logopp-32x32.png'
-    search_url = base_url + '/'
-    manga_url = base_url + '/manga/{0}/'
-    chapter_url = base_url + '/manga/{0}/{1}/'
-    api_chapters_url = base_url + '/wp-admin/admin-ajax.php'
-    bypass_cf_url = base_url + '/manga/nano-machine-lecture-vf/'
-
-    filters = [
-        {
-            'key': 'statuses',
-            'type': 'select',
-            'name': _('Status'),
-            'description': _('Filter by Statuses'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'on-going', 'name': _('Ongoing'), 'default': False},
-                {'key': 'end', 'name': _('Completed'), 'default': False},
-            ],
-        },
-        {
-            'key': 'types',
-            'type': 'select',
-            'name': _('Type'),
-            'description': _('Filter by Types'),
-            'value_type': 'multiple',
-            'options': [
-                {'key': 'manga', 'name': _('Manga'), 'default': False},
-                {'key': 'manhwa', 'name': _('Manhwa'), 'default': False},
-                {'key': 'manhua', 'name': _('Manhua'), 'default': False},
-            ],
-        },
-    ]
-    long_strip_genres = ['Manhwa', 'Webtoon']
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],  # not available
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        ))
-
-        data['name'] = soup.select_one('.serie-title').text.strip()
-        data['cover'] = soup.select_one('img.cover').get('src')
-
-        # Details
-        data['genres'] = [element.text.strip() for element in soup.select('.genre-list .grx div')]
-        if element := soup.select_one('.stat-item .stat-details:-soup-contains("Type") .manga'):
-            type = element.text.strip()
-            if type not in data['genres']:
-                data['genres'].append(type)
-
-        if element := soup.select_one('.stat-item .stat-details:-soup-contains("État du titre") .manga'):
-            status = element.text.strip()
-            if status == 'En cours':
-                data['status'] = 'ongoing'
-            elif status == 'Terminé':
-                data['status'] = 'complete'
-
-        if element := soup.select_one('.stat-item .stat-details:-soup-contains("Auteur") .stat-value'):
-            data['authors'].append(element.text.strip())
-        if element := soup.select_one('.stat-item .stat-details:-soup-contains("Artiste") .stat-value'):
-            artist = element.text.strip()
-            if artist not in data['authors']:
-                data['authors'].append(artist)
-
-        # Synopsis
-        data['synopsis'] = soup.select_one('.description').text.strip()
-
-        # Chapters
-        for element in reversed(soup.select('ul li.item')):
-            if 'premium-chapter' in element.get('class'):
-                continue
-            a_element = element.select_one('a')
-            date_element = a_element.select_one('.chapter-meta')
-            slug = a_element.get('href').split('/')[-1]
-
-            data['chapters'].append(dict(
-                slug=slug,
-                title=a_element.get('title'),
-                num=slug if is_number(slug) else None,
-                date=convert_date_string(date_element.text.strip(), languages=[self.lang]) if date_element else None,
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content + API
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        cursor = None
-        form_data = {}
-        for script_element in soup.select('script'):
-            script = script_element.string
-            if not script or 'rjfr_' not in script:
-                continue
-
-            if matches := re.match(r'.*length]=(.*);', script):
-                req_data = json.loads(matches.group(1))
-
-                encoded = ''
-                for key in req_data['m'].split('|'):
-                    encoded += req_data['c'][key]
-
-                decoded = json.loads(base64.b64decode(encoded + '=='))
-
-                req_data = [None] * len(decoded['d'])
-                for index, pos in enumerate(decoded['m']):
-                    pos = decoded['l'].index(pos)
-                    req_data[pos] = decoded['d'][index]
-
-                req_names = req_data[-2]
-                resp_names = req_data[-1]
-
-                form_data['action'] = req_data[12]
-                mapping = {
-                    0: 1,
-                    1: 2,
-                    2: 3,
-                    3: 4,
-                    4: 5,
-                    5: 6,
-                    6: 8,
-                    7: 9,
-                    8: 7,
-                }
-                for src, dst in mapping.items():
-                    form_data[req_names[src]] = req_data[dst]
-
-                break
-
-        if not form_data:
-            return None
-
-        data = dict(
-            pages=[],
-        )
-        more = True
-        while more:
-            if cursor:
-                form_data[req_names[9]] = cursor
-
-            r = self.session_post(
-                self.api_chapters_url,
-                data=form_data,
-                headers={
-                    'Accept': '*/*',
-                    'Origin': self.base_url,
-                    'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-                    'X-Requested-With': 'XMLHttpRequest',
-                }
-            )
-
-            resp_data = r.json()
-            cursor = resp_data[resp_names[1]][resp_names[8]]
-            more = resp_data[resp_names[1]][resp_names[9]]
-            for image in resp_data[resp_names[1]][resp_names[2]]:
-                data['pages'].append({
-                    'image': image[resp_names[4]],
-                    'slug': None,
-                })
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('/')[-1],
-        )
-
-    @CompleteChallenge()
-    def get_latest_updates(self, statuses=None, types=None):
-        """
-        Returns recent mangas
-        """
-        return self.search(None, statuses=statuses, types=types, orderby='recently_added')
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    @CompleteChallenge()
-    def get_most_populars(self, statuses=None, types=None):
-        """
-        Returns most viewed mangas
-        """
-        return self.search(None, statuses=statuses, types=types, orderby='most_viewed')
-
-    @CompleteChallenge()
-    def search(self, term, statuses=None, types=None, orderby=None):
-        params = {
-            'post_type': 'wp-manga',
-            's': term or '',
-        }
-        if statuses:
-            params['status[]'] = statuses
-        if types:
-            params['type[]'] = types
-        if orderby:
-            params['sort'] = orderby
-
-        r = self.session_get(self.base_url, params=params)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.select('.unit'):
-            a_element = element.select_one('.info a')
-            img_element = element.select_one('.poster > div > img')
-            last_chapter_element = element.select_one('.info ul li:first-child .ch-num')
-
-            results.append({
-                'slug': a_element.get('href').split('/')[-2],
-                'name': a_element.text.strip(),
-                'cover': img_element.get('src'),
-                'last_chapter': last_chapter_element.text.strip().split()[-1],
-            })
-
-        return results
diff --git a/komikku/servers/rawmanga/__init__.py b/komikku/servers/rawmanga/__init__.py
deleted file mode 100644
index 1cd0f596..00000000
--- a/komikku/servers/rawmanga/__init__.py
+++ /dev/null
@@ -1,172 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from bs4 import BeautifulSoup
-
-from komikku.servers.multi.manga_stream import MangaStream
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-
-class Rawmanga(MangaStream):
-    id = 'rawmanga'
-    name = 'Raw Manga 生漫画'
-    lang = 'ja'
-    is_nsfw = True
-    status = 'disabled'
-
-    has_cf = True
-
-    base_url = 'https://mangaraw.org'
-    search_url = base_url + '/search'
-    manga_url = base_url + '/{0}'
-    chapter_url = base_url + '/{manga_slug}/{chapter_slug}'
-    page_url = base_url + '/{0}/{1}/{2}'
-
-    name_selector = '.infox h1'
-    authors_selector = '.infox span:-soup-contains("Author")'
-    genres_selector = '.infox span:-soup-contains("Genres") a'
-    scanlators_selector = '.infox span:-soup-contains("Serialization")'
-    status_selector = '.infox span:-soup-contains("Status")'
-    synopsis_selector = '[itemprop="articleBody"]'
-
-    def get_manga_chapters_data(self, soup):
-        chapters = []
-        for item in reversed(soup.select('.bixbox li')):
-            a_element = item.find('a')
-
-            slug = a_element.get('href').split('/')[-1]
-            ignore = False
-            for keyword in self.ignored_chapters_keywords:
-                if keyword in slug:
-                    ignore = True
-                    break
-            if ignore:
-                continue
-
-            chapters.append(dict(
-                slug=slug,
-                title=a_element.text.strip(),
-                num=slug if is_number(slug) else None,
-                date=convert_date_string(item.find('time').get('title').split()[0], format='%Y-%m-%d'),
-            ))
-
-        return chapters
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(
-            self.chapter_url.format(manga_slug=manga_slug, chapter_slug=chapter_slug),
-            headers={
-                'Referer': self.manga_url.format(manga_slug),
-            })
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-
-        for option_element in soup.find_all('select', {'name': 'page'})[0].find_all('option'):
-            data['pages'].append(dict(
-                slug=option_element.get('value'),
-                image=None,
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        headers = {
-            'Referer': self.chapter_url.format(manga_slug=manga_slug, chapter_slug=chapter_slug),
-        }
-        r = self.session_get(self.page_url.format(manga_slug, chapter_slug, page['slug']), headers=headers)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-        image_url = soup.select_one('.reader a img.picture').get('src')
-
-        r = self.session_get(image_url, headers=headers)
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=image_url.split('/')[-1],
-        )
-
-    def get_latest_updates(self, type):
-        """
-        Returns list of latest updates
-        """
-        return self.search('', type, orderby='latest')
-
-    def get_most_populars(self, type):
-        """
-        Returns list of most popular manga
-        """
-        return self.search('', type, orderby='populars')
-
-    @CompleteChallenge()
-    def search(self, term, type, orderby=None):
-        if orderby:
-            data = dict(
-                order='popular' if orderby == 'populars' else 'update',
-                status='',
-                type=type,
-            )
-        else:
-            data = dict(
-                s=term,
-                author='',
-                released='',
-                status='',
-                order='title',
-                type=type,
-            )
-
-        r = self.session_get(self.search_url, params=data)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for a_element in soup.select('.bsx > a'):
-            type_ = a_element.select_one('.type').text.strip()
-            if type_ == 'Novel':
-                continue
-
-            img_element = a_element.select_one('img')
-            slug = a_element.get('href').split('/')[-1]
-
-            results.append(dict(
-                slug=slug,
-                name=img_element.get('alt').strip(),
-                cover=img_element.get('src'),
-            ))
-
-        return results
diff --git a/komikku/servers/readcomiconline/__init__.py b/komikku/servers/readcomiconline/__init__.py
deleted file mode 100644
index 4ba03f70..00000000
--- a/komikku/servers/readcomiconline/__init__.py
+++ /dev/null
@@ -1,304 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import base64
-from bs4 import BeautifulSoup
-import logging
-import re
-from urllib.parse import unquote
-
-try:
-    # This server requires JA3/TLS and HTTP2 fingerprints impersonation
-    from curl_cffi import requests
-except Exception:
-    # Server will be disabled
-    requests = None
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-logger = logging.getLogger('komikku.servers.readcomiconline')
-
-
-class Readcomiconline(Server):
-    id = 'readcomiconline'
-    name = 'Read Comic Online'
-    lang = 'en'
-    is_nsfw = True
-    status = 'enabled' if requests is not None else 'disabled'
-
-    has_cf = True
-    has_captcha = True  # Custom captcha AreYouHuman2
-    http_client = 'curl_cffi'
-
-    base_url = 'https://rcostation.xyz'
-    latest_updates_url = base_url + '/ComicList/LatestUpdate'
-    most_populars_url = base_url + '/ComicList/MostPopular'
-    search_url = base_url + '/AdvanceSearch'
-    manga_url = base_url + '/Comic/{0}'
-    chapter_url = base_url + '/Comic/{0}/{1}?s=&quality=hq#1'
-    bypass_cf_url = base_url + '/Comic/Invincible/Issue-1#1'
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns comic data by scraping manga HTML page content
-
-        Initial data should contain at least comic's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug'], 1))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],  # not available
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        ))
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        info_element = soup.select_one('#leftside .barContent')
-
-        data['name'] = info_element.select_one('.bigChar').text.strip()
-        cover_path = soup.select_one('#rightside img').get('src')
-        if cover_path.startswith('http'):
-            data['cover'] = cover_path
-        else:
-            data['cover'] = '{0}{1}'.format(self.base_url, cover_path)
-
-        for p_element in info_element.select('p'):
-            if not p_element.span:
-                if not data['synopsis']:
-                    data['synopsis'] = p_element.text.strip()
-                continue
-
-            span_element = p_element.span.extract()
-            label = span_element.text.strip()
-
-            if label.startswith('Genres'):
-                data['genres'] = [a_element.text.strip() for a_element in p_element.select('a')]
-
-            elif label.startswith(('Writer', 'Artist')):
-                for a_element in p_element.select('a'):
-                    value = a_element.text.strip()
-                    if value not in data['authors']:
-                        data['authors'].append(value)
-
-            elif label.startswith('Status'):
-                value = p_element.text.strip()
-                if 'Completed' in value:
-                    data['status'] = 'complete'
-                elif 'Ongoing' in value:
-                    data['status'] = 'ongoing'
-
-        # Chapters (Issues)
-        for tr_element in reversed(soup.select('.listing tr')):
-            td_elements = tr_element.select('td')
-            if not td_elements:
-                continue
-
-            slug = td_elements[0].a.get('href').split('?')[0].split('/')[-1]
-            num = slug.split('-')[-1] if slug.startswith('Issue-') else None
-
-            data['chapters'].append(dict(
-                slug=slug,
-                title=td_elements[0].a.text.strip(),
-                num=num if is_number(num) else None,
-                date=convert_date_string(td_elements[1].text.strip(), format='%m/%d/%Y'),
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns comic chapter data
-        """
-        def decode_url(url, substitutions, base_url):
-            # In `Scripts/rguard.min.js?v=1.5.8`
-            if not url.startswith('https'):
-                for substitution in substitutions:
-                    url = url.replace(substitution[0], substitution[1])
-
-                if '?' in url:
-                    url, qs = url.split('?')
-                else:
-                    qs = None
-
-                if '=s0' in url:
-                    url = url.replace('=s0', '')
-                    s = '=s0'
-                elif '=s1600' in url:
-                    url = url.replace('=s1600', '')
-                    s = '=s1600'
-
-                url = url[15:33] + url[50:]
-                url = url[0:len(url) - 11] + url[len(url) - 2] + url[len(url) - 1]
-                url = unquote(unquote(base64.b64decode(url)))
-                url = 'https://2.bp.blogspot.com/' + url[0:13] + url[17:-2] + s
-                if qs:
-                    url += '?' + qs
-
-            if base_url and base_url != '' and url.find('ip=') > 0:
-                url = url.replace('https://2.bp.blogspot.com', base_url)
-
-            return url
-
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        var_rnd_name = None
-        encoded_urls = []
-        substitutions = []
-        media_server = None
-        for script_element in soup.select('script'):
-            script = script_element.string
-            if not script or ('var pth' not in script and 'replace(' not in script):
-                continue
-
-            for line in script.split('\n'):
-                line = line.strip()
-
-                # URLs (encoded) are stored in a variable with a random name
-                if var_rnd_name is None:
-                    # Search for a line of the form:
-                    # var _1oXikvMxnz = '';
-                    if matches := re.search(r"var\s+_([0-9a-zA-Z]+)\s+=\s+''", line):
-                        var_rnd_name = '_' + matches.group(1)
-
-                if var_rnd_name and line.startswith(var_rnd_name):
-                    encoded_urls.append(line.split()[2][1:-2])
-
-                elif 'replace(' in line:
-                    if matches := re.search(r"replace\(/([a-zA-Z0-9_]+)/g, '([a-z])'\);", line):
-                        substitutions.append((matches.group(1), matches.group(2)))
-
-        data = dict(
-            pages=[],
-        )
-
-        for index, url in enumerate(encoded_urls):
-            data['pages'].append(dict(
-                image=decode_url(url, substitutions, media_server),
-                slug=None,
-                index=index + 1,
-            ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            page['image'] + '&t=10',
-            headers={
-                'Alt-Used': '2.bp.blogspot.com',
-            }
-        )
-
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=f"{page['index']}.{mime_type.split('/')[1]}",
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns comic absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    def get_manga_list(self, term=None, orderby=None):
-        results = []
-
-        if term:
-            r = self.session_get(
-                self.search_url,
-                params=dict(
-                    comicName=term,
-                    ig='',
-                    eg='',
-                    status='',
-                    pubDate='',
-                ),
-                headers={
-                    'Referer': self.search_url,
-                }
-            )
-        elif orderby == 'populars':
-            r = self.session_get(self.most_populars_url)
-        elif orderby == 'latest':
-            r = self.session_get(self.latest_updates_url)
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        for a_element in soup.select('.item > a:first-child'):
-            if not a_element.get('href'):
-                continue
-
-            cover = a_element.img.get('src')
-            if not cover.startswith('http'):
-                cover = self.base_url + cover
-
-            results.append(dict(
-                name=a_element.span.text.strip(),
-                slug=a_element.get('href').split('/')[-1],
-                cover=cover,
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def get_latest_updates(self):
-        """
-        Returns latest updates
-        """
-        return self.get_manga_list(orderby='latest')
-
-    @CompleteChallenge()
-    def get_most_populars(self):
-        """
-        Returns most popular comics
-        """
-        return self.get_manga_list(orderby='populars')
-
-    @CompleteChallenge()
-    def search(self, term):
-        return self.get_manga_list(term=term)
diff --git a/komikku/servers/readcomicsonline/__init__.py b/komikku/servers/readcomicsonline/__init__.py
deleted file mode 100644
index 962411dc..00000000
--- a/komikku/servers/readcomicsonline/__init__.py
+++ /dev/null
@@ -1,257 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2026 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from gettext import gettext as _
-
-from bs4 import BeautifulSoup
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
-
-
-class Readcomicsonline(Server):
-    id = 'readcomicsonline'
-    name = 'Read Comics Online'
-    lang = 'en'
-
-    has_cf = True
-
-    base_url = 'https://readcomicsonline.ru'
-    search_url = base_url + '/advanced-search'
-    manga_list_url = base_url + '/comic-list'
-    manga_url = base_url + '/comic/{0}'
-    chapter_url = base_url + '/comic/{0}/{1}'
-    image_url = 'https://cdn.readcomicsonline.ru/uploads/manga/{0}/chapters/{1}/{2}'
-
-    filters = [
-        {
-            'key': 'status',
-            'type': 'select',
-            'name': _('Status'),
-            'description': _('Filter by Status'),
-            'value_type': 'single',
-            'default': '',
-            'options': [
-                {'key': '', 'name': _('All')},
-                {'key': '1', 'name': _('Ongoing')},
-                {'key': '2', 'name': _('Complete')},
-            ],
-        },
-        {
-            'key': 'type',
-            'type': 'select',
-            'name': _('Type'),
-            'description': _('Filter by Type'),
-            'value_type': 'single',
-            'default': '',
-            'options': [
-                {'key': '', 'name': _('All')},
-                {'key': '1', 'name': 'DC Comics'},
-                {'key': '2', 'name': 'Marvel Comics'},
-                {'key': '3', 'name': _('Other')},
-            ],
-        },
-    ]
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns comic data by scraping comic HTML page content
-
-        Initial data should contain at least comic's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Comic slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update({
-            'authors': [],
-            'scanlators': [],  # not available
-            'genres': [],
-            'status': None,
-            'synopsis': None,
-            'chapters': [],
-            'server_id': self.id,
-            'cover': None,
-        })
-
-        data['name'] = soup.select_one('main h1').text.strip()
-        data['cover'] = soup.select_one('main img.object-cover').get('src')
-
-        # Details
-        if element := soup.select_one('main span.rounded-full'):
-            status = element.text.strip()
-            if status == 'Complete':
-                data['status'] = 'complete'
-            elif status == 'Ongoing':
-                data['status'] = 'ongoing'
-
-        for element in soup.select('div:-soup-contains("Genres") > a'):
-            data['genres'].append(element.text.strip())
-
-        for element in soup.select('div:-soup-contains("Author") > a'):
-            data['authors'].append(element.text.strip())
-
-        if element := soup.select_one('main p.leading-relaxed'):
-            data['synopsis'] = element.text.strip()
-
-        # Chapters
-        for element in reversed(soup.select('section a')):
-            slug = element.get('href').split('/')[-1]
-            title = element.select_one('span:first-child > span').text.strip()
-            date = element.select_one('span:last-child').text.strip()
-
-            data['chapters'].append({
-                'slug': slug,
-                'title': title,
-                'date': convert_date_string(date, languages=[self.lang], format='%d %b %Y'),
-            })
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns comic chapter data by scraping chapter HTML page content
-
-        Currently, only pages (list of images filenames) are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = {
-            'pages': [],
-        }
-        for element in soup.select('#reader-all img'):
-            slug = element.get('src').split('/')[-1]
-
-            data['pages'].append({
-                'slug': slug,
-                'image': None,
-            })
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            self.image_url.format(manga_slug, chapter_slug, page['slug']),
-            headers={
-                'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return {
-            'buffer': r.content,
-            'mime_type': mime_type,
-            'name': page['slug'],
-        }
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns comic absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    def get_manga_list(self, term=None, orderby=None):
-        r = self.session_get(
-            self.manga_list_url,
-            headers={
-                'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
-                'Referer': self.manga_list_url,
-            },
-            params={
-                'sort': orderby,
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.select('.grid .group'):
-            a_element = element.select_one('a.line-clamp-2')
-
-            results.append({
-                'name': a_element.text.strip(),
-                'slug': a_element.get('href').split('/')[-1],
-                'cover': element.select_one('img.object-cover').get('src'),
-                'last_chapter': element.select_one('a.text-xs').text.strip(),
-            })
-
-        return results
-
-    @CompleteChallenge()
-    def get_latest_updates(self, status=None, type=None):
-        """
-        Returns list of latest updated comics
-        """
-        return self.get_manga_list(orderby='latest')
-
-    @CompleteChallenge()
-    def get_most_populars(self, status=None, type=None):
-        """
-        Returns list of most viewed comics
-        """
-        return self.get_manga_list(orderby='views')
-
-    @CompleteChallenge()
-    def search(self, term, status=None, type=None):
-        r = self.session_get(self.search_url)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        if input := soup.select_one('input[name="_token"]'):
-            token = input.get('value')
-        else:
-            return None
-
-        r = self.session_post(
-            self.search_url,
-            params={
-                '_token': token,
-                'name':	term,
-                'status_id': status if status is not None else '',
-                'type_id': type if type is not None else '',
-                'category':	'',
-            }
-        )
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for element in soup.select('.grid a.group'):
-            slug = element.get('href').split('/')[-1]
-
-            results.append({
-                'name': element.p.text.strip(),
-                'slug': slug,
-                'cover': element.select_one('.rc-cover img').get('src'),
-            })
-
-        return results
diff --git a/komikku/servers/remanga/__init__.py b/komikku/servers/remanga/__init__.py
deleted file mode 100644
index d9aadb98..00000000
--- a/komikku/servers/remanga/__init__.py
+++ /dev/null
@@ -1,266 +0,0 @@
-# SPDX-FileCopyrightText: 2022-2024 CakesTwix
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: CakesTwix <oleg.kiryazov@gmail.com>
-
-import logging
-import re
-import time
-from urllib.parse import urlparse
-
-from komikku.servers import Server
-from komikku.servers.utils import convert_date_string
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-logger = logging.getLogger('komikku.servers.remanga')
-
-re_tags_remove = re.compile(r'<[^>]+>')
-
-
-class Remanga(Server):
-    id = 'remanga'
-    name = 'Remanga'
-    lang = 'ru'
-
-    has_captcha = True
-
-    base_url = 'https://xn--80aaig9ahr.xn--c1avg'
-    manga_url = base_url + '/manga/{0}'
-    chapter_url = base_url + '/manga/{0}/{1}'
-    api_base_url = 'https://api.xn--80aaig9ahr.xn--c1avg'
-    api_search_url = api_base_url + '/api/search/'
-    api_manga_list_url = api_base_url + '/api/search/catalog/'
-    api_manga_url = api_base_url + '/api/titles/{0}/'
-    api_chapters_url = api_base_url + '/api/titles/chapters/'
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data from API
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Slug is missing in initial data'
-
-        r = self.session_get(
-            self.api_manga_url.format(initial_data['slug']),
-            headers={
-                'content-type': 'application/json',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        resp_data = r.json()['content']
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-        ))
-
-        data['name'] = resp_data['rus_name']
-        data['cover'] = self.base_url + resp_data['img']['high']
-
-        # Details
-        data['scanlators'] = [publisher['name'] for publisher in resp_data['publishers']]
-        data['genres'] = [genre['name'] for genre in resp_data['genres']]
-
-        if resp_data['status']['id'] == 1:
-            data['status'] = 'ongoing'
-        elif resp_data['status']['id'] == 0:
-            data['status'] = 'complete'
-        elif resp_data['status']['id'] == 2:
-            data['status'] = 'suspended'
-
-        # Synopsis
-        data['synopsis'] = re_tags_remove.sub('', resp_data['description'])
-
-        # Chapters
-        chapters = []
-        branch_id = resp_data['branches'][0]['id']
-        page = 1
-        while True:
-            start = time.time()
-            r = self.session_get(
-                self.api_chapters_url,
-                params=dict(
-                    branch_id=branch_id,
-                    ordering='-index',
-                    user_data=1,
-                    count=40,
-                    page=page,
-                ),
-                headers={
-                    'Content-Type': 'application/json',
-                    'Referer': self.base_url,
-                }
-            )
-            if r.status_code != 200:
-                # Return all chapters or nothing
-                return data
-
-            resp_data = r.json()['content']
-            if not resp_data:
-                break
-
-            for chapter in resp_data:
-                title = '#{0}'.format(chapter['chapter'])
-                if chapter['name']:
-                    title = '{0} - {1}'.format(title, chapter['name'])
-
-                chapters.append(dict(
-                    slug=str(chapter['id']),  # must be a string
-                    title=title,
-                    num=chapter['chapter'] if is_number(chapter.get('chapter')) else None,
-                    num_volume=chapter['tome'] if is_number(chapter.get('tome')) else None,
-                    date=convert_date_string(chapter['upload_date'][:-10], '%Y-%m-%d'),
-                ))
-            page += 1
-
-            delay = min(3 * (time.time() - start), 1)
-            time.sleep(delay)
-
-        data['chapters'] = list(reversed(chapters))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data from API
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(
-            self.api_chapters_url + str(chapter_slug),
-            headers={
-                'content-type': 'application/json',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        resp_data = r.json()['content']
-
-        data = dict(
-            pages=[],
-        )
-        for page in resp_data['pages']:
-            if isinstance(page, list):
-                for page_list in page:
-                    data['pages'].append(dict(
-                        slug=None,
-                        image=page_list['link'],
-                    ))
-            else:
-                data['pages'].append(dict(
-                    slug=None,
-                    image=page['link'],
-                ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Host': urlparse(page['image']).netloc,
-                'Referer': f'{self.base_url}/',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, _url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    def get_manga_list(self, orderby):
-        r = self.session_get(
-            self.api_manga_list_url,
-            params={
-                'count': 50,
-                'exclude_bookmarks': '',
-                'ordering': '-views' if orderby == 'populars' else '-chapter_date',
-                'page': 1,
-            },
-            headers={
-                'Content-Type': 'application/json',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        results = []
-        for item in r.json()['content']:
-            results.append(dict(
-                slug=item['dir'],
-                name=item['rus_name'],
-                cover=self.base_url + item['img']['mid'],
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def get_latest_updates(self):
-        """
-        Returns latest updates
-        """
-        return self.get_manga_list(orderby='latest')
-
-    @CompleteChallenge()
-    def get_most_populars(self):
-        """
-        Returns most popular mangas
-        """
-        return self.get_manga_list(orderby='populars')
-
-    @CompleteChallenge()
-    def search(self, term):
-        r = self.session_get(
-            self.api_search_url,
-            params={
-                'query': term,
-            },
-            headers={
-                'content-type': 'application/json',
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        results = []
-        for item in r.json()['content']:
-            results.append(dict(
-                slug=item['dir'],
-                name=item['rus_name'],
-                cover=self.base_url + item['img']['mid'],
-            ))
-
-        return results
diff --git a/komikku/servers/scanmanga/__init__.py b/komikku/servers/scanmanga/__init__.py
deleted file mode 100644
index 32812a9d..00000000
--- a/komikku/servers/scanmanga/__init__.py
+++ /dev/null
@@ -1,286 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from bs4 import BeautifulSoup
-
-from komikku.servers import Server
-from komikku.utils import get_buffer_mime_type
-from komikku.webview import CompleteChallenge
-
-
-class Scanmanga(Server):
-    id = 'scanmanga'
-    name = 'Scan Manga'
-    lang = 'fr'
-    is_nsfw = True
-    long_strip_genres = ['Webcomic', ]
-    status = 'disabled'  # 2024/03 chapters and chapters images URLs have become difficult to extract (no time for that)
-
-    has_cf = True
-    http_client = 'curl_cffi'
-
-    base_url = 'https://www.scan-manga.com'
-    latest_updates_url = base_url + '/?po'
-    most_populars_url = base_url + '/TOP-Manga-Webtoon-25.html'
-    api_search_url = base_url + '/api/search/quick.json'
-    manga_url = base_url + '{0}'
-    chapter_url = base_url + '/lecture-en-ligne/{0}{1}.html'
-    cover_url = 'https://cdn.scanmanga.eu/img/manga/{0}'
-
-    def __init__(self):
-        self.session = None
-
-    @classmethod
-    def get_manga_initial_data_from_url(cls, url):
-        return dict(
-            url=url.replace(cls.base_url, ''),
-            slug=url.split('/')[-1].replace('.html', ''),
-        )
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's relative url and slug (provided by search)
-        """
-        assert 'url' in initial_data and 'slug' in initial_data, 'Manga url or slug are missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['url']))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-        ))
-
-        data['name'] = soup.find('div', class_='h2_titre').h2.text.strip()
-        if data.get('cover') is None:
-            data['cover'] = soup.find('div', class_='image_manga').img.get('src')
-
-        # Details
-        li_elements = soup.find('div', class_='contenu_texte_fiche_technique').find_all('li')
-        for a_element in li_elements[0].find_all('a'):
-            data['authors'].append(a_element.text.strip())
-
-        data['genres'] = [g.strip() for g in li_elements[1].text.split()]
-        for a_element in li_elements[2].find_all('a'):
-            a_element.span.extract()
-            data['genres'].append(a_element.text.strip())
-
-        status = li_elements[6].text.strip().lower()
-        if status == 'en cours':
-            data['status'] = 'ongoing'
-        elif status in ('one shot', 'terminé'):
-            data['status'] = 'complete'
-        elif status == 'en pause':
-            data['status'] = 'hiatus'
-
-        for a_element in li_elements[7].find_all('a'):
-            data['scanlators'].append(a_element.text.strip())
-
-        # Synopsis
-        p_element = soup.find('div', class_='texte_synopsis_manga').find('p', itemprop='description')
-        p_element.span.extract()
-        data['synopsis'] = p_element.text.strip()
-
-        # Chapters
-        for element in reversed(soup.select('.chapitre_nom')):
-            a_element = element.a
-            if not a_element or element.select_one('.typcn-lock-closed') or element.select_one('.typcn-lock-open'):
-                # Skip external chapters
-                continue
-
-            data['chapters'].append(dict(
-                slug=a_element.get('href').split('/')[-1].replace(data['slug'], '').replace('.html', ''),
-                title=element.text.strip(),
-                date=None,
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-
-        for script_element in soup.find_all('script'):
-            script = script_element.string
-            if not script or 'var nPa = new Array' not in script:
-                continue
-
-            image_base_url = None
-            for line in script.split('\n'):
-                line = line.strip()
-
-                if 'var nPa = new Array' in line:
-                    array_name = line.split(' ')[1]
-
-                    for item in line.split(';'):
-                        if not item.startswith(f'{array_name}['):
-                            continue
-
-                        data['pages'].append(dict(
-                            slug=None,
-                            image=item.split('"')[1],
-                        ))
-
-                elif line.startswith(('tlo =', "$('#preload')")):
-                    image_base_url = line.split("'")[-2]
-                    break
-
-            if image_base_url:
-                for page in data['pages']:
-                    page['image'] = image_base_url + page['image']
-
-            break
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        self.create_session()  # Session must be refreshed each time
-
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('?')[0].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(url)
-
-    @CompleteChallenge()
-    def get_latest_updates(self):
-        """
-        Returns latest updates
-        """
-        r = self.session_get(self.latest_updates_url)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for a_element in soup.select('#content_news .nom_manga'):
-            name = a_element.text.strip()
-            if 'Novel' in name:
-                continue
-
-            results.append(dict(
-                name=name,
-                slug=a_element.get('href').split('/')[-1].replace('.html', ''),
-                url=a_element.get('href').replace(self.base_url, ''),
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def get_most_populars(self):
-        """
-        Returns list of top manga
-        """
-        r = self.session_get(self.most_populars_url)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for cover_element in soup.select('.image_manga'):
-            element = cover_element.find_next_siblings()[0]
-
-            a_element = element.h3.a
-            name = a_element.text.strip()
-            if 'Novel' in name:
-                # Skip
-                continue
-
-            results.append(dict(
-                name=a_element.text.strip(),
-                slug=a_element.get('href').split('/')[-1].replace('.html', ''),
-                url=a_element.get('href').replace(self.base_url, ''),
-                cover=cover_element.select_one('img').get('data-original'),
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def search(self, term):
-        r = self.session_get(
-            self.api_search_url,
-            params=dict(term=term),
-            default_headers=True,
-            headers={
-                'Accept': '*/*',
-                'Content-Type': 'application/json; charset=UTF-8',
-                'Referer': f'{self.base_url}/',
-            }
-        )
-
-        if r.status_code == 200:
-            try:
-                data = r.json()
-                results = []
-                for item in data['title']:
-                    if item['type'] == 'Novel':
-                        # Skip
-                        continue
-
-                    results.append(dict(
-                        url=item['url'],
-                        slug=item['url'].split('/')[-1].replace('.html', ''),
-                        name=item['nom_match'],
-                        cover=self.cover_url.format(item['image']),
-                        last_chapter=str(item['l_ch']),
-                    ))
-            except Exception:
-                return None
-            else:
-                return results
-
-        return None
diff --git a/komikku/servers/teamx/__init__.py b/komikku/servers/teamx/__init__.py
deleted file mode 100644
index b920af55..00000000
--- a/komikku/servers/teamx/__init__.py
+++ /dev/null
@@ -1,232 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from bs4 import BeautifulSoup
-
-from komikku.servers import Server
-from komikku.utils import get_buffer_mime_type
-from komikku.utils import is_number
-from komikku.webview import CompleteChallenge
-
-
-class Teamx(Server):
-    id = 'teamx'
-    name = 'Team-X'
-    lang = 'ar'
-
-    has_cf = True
-
-    base_url = 'https://olympustaff.com'
-    logo_url = base_url + '/assets/images/favicon.png'
-    search_url = base_url + '/ajax/search'
-    manga_url = base_url + '/series/{0}'
-    chapter_url = base_url + '/series/{0}/{1}'
-
-    def __init__(self):
-        self.session = None
-
-    @CompleteChallenge()
-    def get_manga_data(self, initial_data):
-        """
-        Returns manga data by scraping manga HTML page content
-
-        Initial data should contain at least manga's slug (provided by search)
-        """
-        assert 'slug' in initial_data, 'Manga slug is missing in initial data'
-
-        r = self.session_get(self.manga_url.format(initial_data['slug']))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = initial_data.copy()
-        data.update(dict(
-            authors=[],
-            scanlators=[],
-            genres=[],
-            status=None,
-            synopsis=None,
-            chapters=[],
-            server_id=self.id,
-            cover=None,
-        ))
-
-        data['name'] = soup.select_one('.author-info-title h1').text.strip()
-        data['cover'] = soup.select_one('.whitebox > .text-right > img').get('src')
-
-        # Details
-        data['genres'] = [a_element.text.strip() for a_element in soup.select('.review-author-info > a')]
-
-        for element in soup.select('.whitebox > .text-right .full-list-info'):
-            label = element.small.text.strip()
-            a_elements = element.select('small:nth-child(2) a')
-
-            if label.startswith('الحالة'):
-                value = a_elements[0].text.strip()
-                if value in ('قادم قريبًا', 'مستمرة'):
-                    data['status'] = 'ongoing'
-                elif value == 'مكتمل':
-                    data['status'] = 'complete'
-                elif value == 'متوقف':
-                    data['status'] = 'suspended'
-
-            elif label.startswith('الرسام'):
-                for a_element in a_elements:
-                    value = a_element.text.strip()
-                    if value not in data['authors']:
-                        data['authors'].append(value)
-
-            elif label.startswith('النوع'):
-                for a_element in a_elements:
-                    value = a_element.text.strip()
-                    data['genres'].append(value)
-
-        # Synopsis
-        data['synopsis'] = soup.select_one('.review-content > p').text.strip()
-
-        # Chapters
-        first_chapter_url = soup.select_one('.lastend .inepcx:nth-child(1) a').get('href')
-        r = self.session_get(first_chapter_url)
-        if r.status_code != 200:
-            return data
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        for option_element in reversed(soup.select('#select_chapter option')):
-            chapter_url = option_element.get('value')
-            if not chapter_url:
-                continue
-
-            slug = chapter_url.split('/')[-1]
-
-            data['chapters'].append(dict(
-                slug=slug,
-                title=' '.join(option_element.text.strip().split()),
-                num=slug if is_number(slug) else None,
-                date=None,
-            ))
-
-        return data
-
-    @CompleteChallenge()
-    def get_manga_chapter_data(self, manga_slug, manga_name, chapter_slug, chapter_url):
-        """
-        Returns manga chapter data by scraping chapter HTML page content
-
-        Currently, only pages are expected.
-        """
-        r = self.session_get(self.chapter_url.format(manga_slug, chapter_slug))
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if mime_type != 'text/html':
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        data = dict(
-            pages=[],
-        )
-        for img_element in soup.select('.image_list canvas[data-src], .image_list img[src]'):
-            data['pages'].append(dict(
-                image=img_element.get('src') or img_element.get('data-src'),
-                slug=None,
-            ))
-
-        return data
-
-    def get_manga_chapter_page_image(self, manga_slug, manga_name, chapter_slug, page):
-        """
-        Returns chapter page scan (image) content
-        """
-        r = self.session_get(
-            page['image'],
-            headers={
-                'Referer': self.chapter_url.format(manga_slug, chapter_slug),
-            }
-        )
-        if r.status_code != 200:
-            return None
-
-        mime_type = get_buffer_mime_type(r.content)
-        if not mime_type.startswith('image'):
-            return None
-
-        return dict(
-            buffer=r.content,
-            mime_type=mime_type,
-            name=page['image'].split('/')[-1],
-        )
-
-    def get_manga_url(self, slug, url):
-        """
-        Returns manga absolute URL
-        """
-        return self.manga_url.format(slug)
-
-    @CompleteChallenge()
-    def get_latest_updates(self):
-        """
-        Returns latest updates
-        """
-        r = self.session_get(self.base_url)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for a_element in soup.select('.imgu a'):
-            results.append(dict(
-                slug=a_element.get('href').split('/')[-1],
-                name=a_element.img.get('alt'),
-                cover=a_element.img.get('src'),
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def get_most_populars(self):
-        """
-        Returns most viewed mangas
-        """
-        r = self.session_get(self.base_url)
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for a_element in soup.select('.swiper-slide .entry-image a'):
-            results.append(dict(
-                slug=a_element.get('href').split('/')[-1],
-                name=a_element.img.get('alt'),
-                cover=a_element.img.get('src'),
-            ))
-
-        return results
-
-    @CompleteChallenge()
-    def search(self, term):
-        r = self.session_get(self.search_url, params=dict(keyword=term))
-        if r.status_code != 200:
-            return None
-
-        soup = BeautifulSoup(r.text, 'lxml')
-
-        results = []
-        for a_element in soup.select('a'):
-            results.append(dict(
-                slug=a_element.get('href').split('/')[-1],
-                name=a_element.select_one('h4').text.strip(),
-                cover=a_element.select_one('img').get('src'),
-            ))
-
-        return results
diff --git a/komikku/trackers/anilist/__init__.py b/komikku/trackers/anilist/__init__.py
deleted file mode 100644
index c9514e1e..00000000
--- a/komikku/trackers/anilist/__init__.py
+++ /dev/null
@@ -1,362 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import logging
-from urllib.parse import parse_qs
-from urllib.parse import urlparse
-
-import requests
-
-from komikku.consts import USER_AGENT
-from komikku.trackers import authenticated
-from komikku.trackers import Tracker
-from komikku.webview import get_tracker_access_token
-
-logger = logging.getLogger(__name__)
-
-# https://docs.anilist.co/guide/graphql/
-# https://studio.apollographql.com/sandbox/schema/reference
-
-
-class Anilist(Tracker):
-    id = 'anilist'
-    name = 'AniList'
-    client_id = '21273'
-
-    base_url = 'https://anilist.co'
-    logo_url = base_url + '/img/icons/favicon-32x32.png'
-    authorize_url = f'{base_url}/api/v2/oauth/authorize?client_id={client_id}&response_type=token'
-    api_url = 'https://graphql.anilist.co'
-    manga_url = base_url + '/manga/{0}'
-
-    RELEASE_STATUSES = {
-        'CANCELLED': 'Cancelled',
-        'FINISHED': 'Finished',
-        'HIATUS': 'Hiatus',
-        'NOT_YET_RELEASED': 'Not Yet Released',
-        'RELEASING': 'Releasing',
-    }
-
-    STATUSES_MAPPING = {
-        # tracker status => internal status
-        'CURRENT': 'reading',
-        'COMPLETED': 'completed',
-        'PAUSED': 'on_hold',
-        'DROPPED': 'dropped',
-        'PLANNING': 'plan_to_read',
-        'REPEATING': 'rereading',
-    }
-
-    USER_SCORES_FORMATS = {
-        'POINT_100': {
-            'min': 0,
-            'max': 100,
-            'step': 1,
-            'raw_factor': 1,
-        },
-        'POINT_10_DECIMAL': {
-            'min': 0,
-            'max': 10,
-            'step': .1,
-            'raw_factor': 10,
-        },
-        'POINT_10': {
-            'min': 0,
-            'max': 10,
-            'step': 1,
-            'raw_factor': 10,
-        },
-        'POINT_5': {
-            'min': 0,
-            'max': 5,
-            'step': 1,
-            'raw_factor': 20,
-        },
-        'POINT_3': {
-            'min': 0,
-            'max': 3,
-            'step': 1,
-            'raw_factor': 100 / 3,
-        },
-    }
-
-    def __init__(self):
-        self.session = requests.Session()
-        self.session.headers.update({'User-Agent': USER_AGENT})
-
-    @authenticated
-    def get_user(self, access_token=None):
-        query = """
-            query {
-                Viewer {
-                    id
-                    mediaListOptions {
-                        scoreFormat
-                    }
-                }
-            }
-        """
-        r = self.session_post(
-            self.api_url,
-            json={
-                'query': query,
-            },
-            headers={
-                'Authorization': f'Bearer {access_token}',
-                'Content-Type': 'application/json',
-                'Accept': 'application/json',
-            }
-        )
-        if r.status_code != 200:
-            data = r.json()
-            if errors := data.get('errors'):
-                for error in errors:
-                    logger.error(error['message'])
-            return None
-
-        data = r.json()['data']['Viewer']
-
-        return {
-            'id': data['id'],
-            'score_format': data['mediaListOptions']['scoreFormat'],
-        }
-
-    def get_manga_url(self, id):
-        return self.manga_url.format(id)
-
-    @authenticated
-    def get_tracker_manga_data(self, id, access_token=None):
-        query = """
-            query ($id: Int) {
-                Media (id: $id) {
-                    id
-                    title {
-                        userPreferred
-                    }
-                    chapters
-                }
-                Viewer {
-                    id
-                    mediaListOptions {
-                        scoreFormat
-                    }
-                }
-            }
-        """
-        r = self.session_post(
-            self.api_url,
-            json={
-                'query': query,
-                'variables': {
-                    'id': id,
-                },
-            },
-            headers={
-                'Authorization': f'Bearer {access_token}',
-                'Content-Type': 'application/json',
-                'Accept': 'application/json',
-            }
-        )
-        if r.status_code != 200:
-            data = r.json()
-            if errors := data.get('errors'):
-                for error in errors:
-                    logger.error(error['message'])
-            return None
-
-        data = r.json()['data']
-
-        return {
-            'id': data['Media']['id'],
-            'name': data['Media']['title']['userPreferred'],
-            'chapters': data['Media']['chapters'],
-            'score_format': data['Viewer']['mediaListOptions']['scoreFormat'],
-        }
-
-    def get_user_score_format(self, format):
-        return self.USER_SCORES_FORMATS[format]
-
-    @authenticated
-    def get_user_manga_data(self, id, access_token=None):
-        user = self.get_user()
-
-        query = """
-            query ($id: Int, $mediaId: Int, $userId: Int) {
-                MediaList(id: $id, mediaId: $mediaId, userId: $userId) {
-                    id
-                    progress
-                    score
-                    status
-                    media {
-                        id
-                        title {
-                            userPreferred
-                        }
-                        chapters
-                    }
-                }
-            }
-        """
-        r = self.session_post(
-            self.api_url,
-            json={
-                'query': query,
-                'variables': {
-                    'userId': user['id'],
-                    'mediaId': id,
-                },
-            },
-            headers={
-                'Authorization': f'Bearer {access_token}',
-                'Content-Type': 'application/json',
-                'Accept': 'application/json',
-            }
-        )
-        if r.status_code != 200:
-            data = r.json()
-            if errors := data.get('errors'):
-                for error in errors:
-                    logger.error(error['message'])
-            return None
-
-        data = r.json()['data']['MediaList']
-
-        return {
-            'id': data['media']['id'],
-            'name': data['media']['title']['userPreferred'],
-            'chapters': data['media']['chapters'],
-            'chapters_progress': data['progress'],
-            'score': data['score'],
-            'score_format': user['score_format'],
-            'status': self.STATUSES_MAPPING[data['status']],
-        }
-
-    def refresh_access_token(self):
-        # Access token can't be refreshed
-        # Tracker server don't store the access token
-        return None
-
-    def request_access_token(self):
-        redirect_url, error = get_tracker_access_token(self.authorize_url, self.app_redirect_url)
-
-        if redirect_url:
-            # Access token is in fragment, convert fragment into query string
-            qs = parse_qs(urlparse(redirect_url.replace('#', '?')).query)
-
-            self.data = {
-                'access_token': qs['access_token'][0],
-                'refresh_token': None,
-            }
-
-            return True, None
-
-        return False, error
-
-    def search(self, term):
-        query = """
-            query($id: Int, $search: String, $page: Int=1, $per_page: Int=10) {
-                Page(page: $page, perPage: $per_page) {
-                    pageInfo {
-                        total
-                        currentPage
-                        lastPage
-                    }
-                    media(id: $id, search: $search, type: MANGA, format_not_in: [NOVEL]) {
-                        id
-                        title {
-                            userPreferred
-                        }
-                        status
-                        coverImage {
-                            medium
-                        }
-                        startDate {
-                            year
-                        }
-                        meanScore
-                        description
-                    }
-                }
-            }
-        """
-        r = self.session_post(
-            self.api_url,
-            json={
-                'query': query,
-                'variables': {
-                    'search': term,
-                    'page': 1,
-                    'per_page': 10,
-                },
-            },
-            headers={
-                'Content-Type': 'application/json',
-                'Accept': 'application/json',
-            }
-        )
-        if r.status_code != 200:
-            data = r.json()
-            if errors := data.get('errors'):
-                for error in errors:
-                    logger.error(error['message'])
-            return None
-
-        results = []
-        for item in r.json()['data']['Page']['media']:
-            results.append({
-                'id': item['id'],
-                'cover': item['coverImage']['medium'],
-                'name': item['title']['userPreferred'],
-                'score': item['meanScore'] / 10 if item.get('meanScore') else None,
-                'start_date': str(item['startDate']['year']),
-                'status': self.RELEASE_STATUSES.get(item['status']),
-                'synopsis': item['description'],
-            })
-
-        return results
-
-    @authenticated
-    def update_user_manga_data(self, id, data, access_token=None):
-        user = self.get_user()
-        if user is None:
-            return False
-
-        # Convert score: RAW to user format
-        score = int(data['score'] / self.get_user_score_format(user['score_format'])['raw_factor'])
-
-        # Convert status: internal to tracker naming
-        status = self.convert_internal_status(data['status'])
-
-        progress = int(data['chapters_progress'])
-
-        query = f"""
-            mutation {{
-                SaveMediaListEntry(mediaId: {id}, score: {score}, status: {status}, progress: {progress}) {{
-                    id
-                    mediaId
-                    score
-                    status
-                    progress
-                }}
-            }}
-        """  # noqa: E202
-        r = self.session_post(
-            self.api_url,
-            json={
-                'query': query,
-            },
-            headers={
-                'Authorization': f'Bearer {access_token}',
-                'Content-Type': 'application/json',
-                'Accept': 'application/json',
-            }
-        )
-        if r.status_code != 200:
-            data = r.json()
-            if errors := data.get('errors'):
-                for error in errors:
-                    logger.error(error['message'])
-            return False
-
-        return True
diff --git a/komikku/trackers/myanimelist/__init__.py b/komikku/trackers/myanimelist/__init__.py
deleted file mode 100644
index 08bf3679..00000000
--- a/komikku/trackers/myanimelist/__init__.py
+++ /dev/null
@@ -1,283 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-import base64
-import os
-import re
-from urllib.parse import parse_qs
-from urllib.parse import urlparse
-
-import requests
-
-from komikku.consts import USER_AGENT
-from komikku.trackers import authenticated
-from komikku.trackers import Tracker
-from komikku.webview import get_tracker_access_token
-
-# https://myanimelist.net/apiconfig/references/api/v2
-
-
-class Myanimelist(Tracker):
-    id = 'myanimelist'
-    name = 'MyAnimeList'
-    client_id = '4d0e78e295a090211517bded6ebdaa49'
-
-    base_url = 'https://myanimelist.net'
-    logo_url = base_url + '/images/favicon.ico'
-    auth_url = base_url + '/v1/oauth2'
-    authorize_url = auth_url + '/authorize?response_type=code&client_id={0}&state={1}&code_challenge={2}&code_challenge_method=plain'
-    access_token_url = auth_url + '/token'
-
-    api_url = 'https://api.myanimelist.net/v2'
-    api_search_url = api_url + '/manga'
-    api_manga_url = api_url + '/manga/{0}'
-    api_manga_update_url = api_manga_url + '/my_list_status'
-    api_user_mangalist_url = api_url + '/users/@me/mangalist'
-    manga_url = base_url + '/manga/{0}'
-
-    RELEASE_STATUSES = {
-        'discontinued': 'Cancelled',
-        'finished': 'Finished',
-        'on_hiatus': 'Hiatus',
-        'not_yet_published': 'Not Yet Released',
-        'currently_publishing': 'Releasing',
-    }
-
-    STATUSES_MAPPING = {
-        # tracker status => internal status
-        'reading': 'reading',
-        'completed': 'completed',
-        'on_hold': 'on_hold',
-        'dropped': 'dropped',
-        'plan_to_read': 'plan_to_read',
-        'rereading': 'rereading',  # Do not exists
-    }
-
-    USER_SCORE_FORMAT = {
-        'min': 0,
-        'max': 10,
-        'step': 1,
-        'raw_factor': 1,
-    }
-
-    def __init__(self):
-        self.session = requests.Session()
-        self.session.headers.update({'User-Agent': USER_AGENT})
-
-    def get_manga_url(self, id):
-        return self.manga_url.format(id)
-
-    @authenticated
-    def get_tracker_manga_data(self, id, access_token=None):
-        r = self.session.get(
-            self.api_manga_url.format(id),
-            params={
-                'fields': 'id,num_chapters,title',
-            },
-            headers={
-                'Authorization': f'Bearer {access_token}',
-            }
-        )
-        if r.status_code == 401:
-            if self.refresh_access_token():
-                return self.get_tracker_manga_data(id)
-
-        elif r.status_code != 200:
-            return None
-
-        data = r.json()
-
-        return {
-            'id': data['id'],
-            'name': data['title'],
-            'chapters': data['num_chapters'],
-            'score_format': None,
-        }
-
-    def get_user_score_format(self, format):
-        return self.USER_SCORE_FORMAT
-
-    @authenticated
-    def get_user_manga_data(self, id, access_token=None):
-        r = self.session_get(
-            self.api_user_mangalist_url,
-            params={
-                'limit': 1000,
-                'fields': 'id,my_list_status,num_chapters,title',
-            },
-            headers={
-                'Authorization': f'Bearer {access_token}',
-            }
-        )
-        if r.status_code == 401:
-            if self.refresh_access_token():
-                return self.get_user_manga_data(id)
-
-        elif r.status_code != 200:
-            return None
-
-        data = None
-        for item in r.json()['data']:
-            if item['node']['id'] != id:
-                continue
-
-            if status := item['node']['my_list_status'].get('status'):
-                status = self.STATUSES_MAPPING[status]
-            else:
-                status = 'reading'
-            if status == 'reading' and item['node']['my_list_status']['is_rereading']:
-                status = 'rereading'
-
-            data = {
-                'id': item['node']['id'],
-                'name': item['node']['title'],
-                'chapters': item['node']['num_chapters'],
-                'chapters_progress': item['node']['my_list_status']['num_chapters_read'],
-                'score': item['node']['my_list_status']['score'],
-                'score_format': None,
-                'status': status,
-            }
-
-        return data
-
-    def refresh_access_token(self):
-        r = self.session.post(
-            self.access_token_url,
-            auth=(self.client_id, ''),
-            data={
-                'grant_type': 'refresh_token',
-                'refresh_token': self.data['refresh_token'],
-            },
-            headers={
-                'Content-Type': 'application/x-www-form-urlencoded',
-            }
-        )
-
-        if r.status_code != 200:
-            return None
-
-        data = r.json()
-
-        self.data = {
-            'access_token': data['access_token'],
-            'refresh_token': data['refresh_token'],
-        }
-
-        return data['access_token']
-
-    def request_access_token(self):
-        code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode('utf-8')
-        code_verifier = re.sub('[^a-zA-Z0-9]+', '', code_verifier)
-        state = 'komikku'
-
-        authorize_url = self.authorize_url.format(self.client_id, state, code_verifier)
-
-        redirect_url, error = get_tracker_access_token(authorize_url, self.app_redirect_url)
-
-        if error:
-            return False, error
-
-        qs = parse_qs(urlparse(redirect_url).query)
-        if qs['state'][0] != state:
-            return False, 'failed'
-
-        code = qs['code'][0]
-
-        r = self.session.post(
-            self.access_token_url,
-            data={
-                'client_id': self.client_id,
-                'grant_type': 'authorization_code',
-                'code': code,
-                'code_verifier': code_verifier,
-            },
-            headers={
-                'Content-Type': 'application/x-www-form-urlencoded',
-            }
-        )
-
-        if r.status_code != 200:
-            return False, 'failed'
-
-        data = r.json()
-        self.data = {
-            'access_token': data['access_token'],
-            'refresh_token': data['refresh_token'],
-        }
-
-        return True, None
-
-    @authenticated
-    def search(self, term, access_token=None):
-        r = self.session.get(
-            self.api_search_url,
-            params={
-                'q': term,
-                'limit': 100,
-                'fields': 'id,title,main_picture,status,synopsis,genres,authors{first_name,last_name},mean,start_date',
-            },
-            headers={
-                'Authorization': f'Bearer {access_token}',
-            }
-        )
-        if r.status_code == 401:
-            if self.refresh_access_token():
-                return self.search(term)
-
-        elif r.status_code != 200:
-            return None
-
-        results = []
-        for item in r.json()['data']:
-            authors = []
-            if item['node'].get('authors'):
-                for author in item['node']['authors']:
-                    full = f'{author["node"]["first_name"]} {author["node"]["last_name"]}'.strip()
-                    if author['role']:
-                        full = f'{full} ({author["role"]})'
-                    authors.append(full)
-
-            results.append({
-                'id': item['node']['id'],
-                'authors': ', '.join(authors),
-                'cover': item['node']['main_picture']['medium'] if item['node'].get('main_picture') else None,
-                'name': item['node']['title'],
-                'score': item['node']['mean'] if item['node'].get('mean') else None,
-                'start_date': item['node']['start_date'][:4] if item['node'].get('start_date') else None,
-                'status': self.RELEASE_STATUSES.get(item['node']['status']),
-                'synopsis': item['node']['synopsis'],
-            })
-
-        return results
-
-    @authenticated
-    def update_user_manga_data(self, id, data, access_token=None):
-        update_data = data.copy()
-
-        # chapters_progress => num_chapters_read
-        num_chapters_read = update_data.pop('chapters_progress', None)
-        if num_chapters_read is not None:
-            update_data['num_chapters_read'] = num_chapters_read
-
-        if update_data.get('status') == 'rereading':
-            update_data['status'] = 'reading'
-            update_data['is_rereading'] = True
-
-        update_data['status'] = self.convert_internal_status(update_data['status'])
-
-        r = self.session_put(
-            self.api_manga_update_url.format(id),
-            data=update_data,
-            headers={
-                'Authorization': f'Bearer {access_token}',
-            }
-        )
-        if r.status_code == 401:
-            if self.refresh_access_token():
-                return self.update_user_manga_data(id, data)
-
-        elif r.status_code != 200:
-            return False
-
-        return True
diff --git a/komikku/webview.py b/komikku/webview.py
deleted file mode 100644
index 9cd73303..00000000
--- a/komikku/webview.py
+++ /dev/null
@@ -1,845 +0,0 @@
-# SPDX-FileCopyrightText: 2019-2025 Valéry Febvre
-# SPDX-License-Identifier: GPL-3.0-or-later
-# Author: Valéry Febvre <vfebvre@easter-eggs.com>
-
-from collections import deque
-from gettext import gettext as _
-import logging
-import os
-import platform
-import threading
-import time
-import tzlocal
-from urllib.parse import urlsplit
-
-try:
-    from curl_cffi import requests as crequests
-except Exception:
-    crequests = None
-import gi
-import requests
-
-gi.require_version('Adw', '1')
-gi.require_version('WebKit', '6.0')
-
-from gi.repository import Adw
-from gi.repository import Gio
-from gi.repository import GLib
-from gi.repository import GObject
-from gi.repository import Gtk
-from gi.repository import WebKit
-
-from komikku.consts import REQUESTS_TIMEOUT
-from komikku.models import create_db_connection
-from komikku.models.database import execute_sql
-from komikku.servers.exceptions import ChallengerError
-from komikku.servers.utils import get_session_cookies
-from komikku.utils import get_webview_data_dir
-
-CF_RELOAD_MAX = 3
-DEBUG = False
-
-logger = logging.getLogger('komikku.webview')
-
-
-@Gtk.Template.from_resource('/info/febvre/Komikku/ui/webview.ui')
-class WebviewPage(Adw.NavigationPage):
-    __gtype_name__ = 'WebviewPage'
-    __gsignals__ = {
-        'cancelled': (GObject.SignalFlags.RUN_FIRST, None, ()),
-    }
-
-    toolbarview = Gtk.Template.Child('toolbarview')
-    title = Gtk.Template.Child('title')
-
-    challenger = None  # Current challenger
-    challengers = deque()  # List of pending challengers
-    concurrent_lock = threading.RLock()
-    exited = True  # Whether webview has been exited (page has been popped)
-    exited_auto = False  # Whether webview has been automatically left (no user interaction)
-    handlers_ids = []  # List of handlers IDs (connected events)
-    handlers_webview_ids = []  # List of hendlers IDs (connected events to WebKit.WebView)
-    lock = False  # Whether webview is locked (in use)
-
-    def __init__(self, window):
-        Adw.NavigationPage.__init__(self)
-
-        self.window = window
-
-        self.connect('hidden', self.on_hidden)
-
-        # User agent: Gnome Web like
-        cpu_arch = platform.machine()
-        session_type = GLib.getenv('XDG_SESSION_TYPE')
-        session_type = session_type.capitalize() if session_type else 'Wayland'
-        custom_part = f'{session_type}; Linux {cpu_arch}'  # noqa: E702
-        self.user_agent = f'Mozilla/5.0 ({custom_part}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/60.5 Safari/605.1.15'
-
-        # Settings
-        self.settings = WebKit.Settings.new()
-        self.settings.set_enable_developer_extras(DEBUG)
-        self.settings.set_enable_write_console_messages_to_stdout(DEBUG)
-
-        # Enable extra features
-        all_feature_list = self.settings.get_all_features()
-        if DEBUG:
-            experimental_feature_list = self.settings.get_experimental_features()
-            development_feature_list = self.settings.get_development_features()
-            experimental_features = [
-                experimental_feature_list.get(index).get_identifier() for index in range(experimental_feature_list.get_length())
-            ]
-            development_features = [
-                development_feature_list.get(index).get_identifier() for index in range(development_feature_list.get_length())
-            ]
-
-            # Categories: Security, Animation, JavaScript, HTML, Other, DOM, Privacy, Media, Network, CSS
-            for index in range(all_feature_list.get_length()):
-                feature = all_feature_list.get(index)
-                if feature.get_identifier() in experimental_features:
-                    type = 'Experimental'
-                elif feature.get_identifier() in development_features:
-                    type = 'Development'
-                else:
-                    type = 'Stable'
-                if feature.get_category() == 'Other' and not feature.get_default_value():
-                    print('ID: {0}, Default: {1}, Category: {2}, Details: {3}, type: {4}'.format(
-                        feature.get_identifier(),
-                        feature.get_default_value(),
-                        feature.get_category(),
-                        feature.get_details(),
-                        type
-                    ))
-
-        extra_features_enabled = (
-            'AllowDisplayOfInsecureContent',
-            'AllowRunningOfInsecureContent',
-            'JavaScriptCanAccessClipboard',
-        )
-        for index in range(all_feature_list.get_length()):
-            feature = all_feature_list.get(index)
-            if feature.get_identifier() in extra_features_enabled and not feature.get_default_value():
-                self.settings.set_feature_enabled(feature, True)
-
-        # Context
-        self.web_context = WebKit.WebContext(time_zone_override=tzlocal.get_localzone_name())
-        self.web_context.set_cache_model(WebKit.CacheModel.DOCUMENT_VIEWER)
-        self.web_context.set_preferred_languages(['en-US', 'en'])
-
-        # Network session
-        self.network_session = WebKit.NetworkSession.new(
-            os.path.join(get_webview_data_dir(), 'data'),
-            os.path.join(get_webview_data_dir(), 'cache')
-        )
-        self.network_session.get_website_data_manager().set_favicons_enabled(True)
-        self.network_session.set_itp_enabled(False)
-        self.network_session.get_cookie_manager().set_accept_policy(WebKit.CookieAcceptPolicy.ALWAYS)
-        self.network_session.get_cookie_manager().set_persistent_storage(
-            os.path.join(get_webview_data_dir(), 'cookies.sqlite'),
-            WebKit.CookiePersistentStorage.SQLITE
-        )
-
-        # Create Webview
-        self.webkit_webview = WebKit.WebView(
-            web_context=self.web_context,
-            network_session=self.network_session,
-            settings=self.settings
-        )
-
-        self.toolbarview.set_content(self.webkit_webview)
-        self.window.navigationview.add(self)
-
-    def cancel_challengers(self, server_ids, context=None):
-        # Cancel pendings
-        if self.challengers:
-            with self.concurrent_lock:
-                for challenger in self.challengers.copy():
-                    if context and challenger.context != context:
-                        continue
-
-                    if challenger.server.id not in server_ids or self.challenger.server.id == challenger.server.id:
-                        continue
-
-                    challenger.cancel()
-                    self.challengers.remove(challenger)
-
-        # Cancel current
-        if self.challenger and self.challenger.server.id in server_ids:
-            self.challenger.cancel()
-            self.exit()
-            self.close_page()
-
-    def clear_data(self, on_finish_callback):
-        def on_finish(data_manager, result):
-            # Clear cookies in SQLite DB
-            if con := create_db_connection(os.path.join(get_webview_data_dir(), 'cookies.sqlite')):
-                execute_sql(con, 'DELETE FROM moz_cookies;')
-
-            on_finish_callback(data_manager.clear_finish(result))
-
-        self.network_session.get_website_data_manager().clear(WebKit.WebsiteDataTypes.ALL, 0, None, on_finish)
-
-    def close_page(self, blank=True):
-        self.disconnect_all_signals()
-
-        if blank:
-            self.webkit_webview.stop_loading()
-            GLib.idle_add(self.webkit_webview.load_uri, 'about:blank')
-
-        def do_next():
-            if not self.exited:
-                return GLib.SOURCE_CONTINUE
-
-            self.lock = False
-            self.pop_challenger()
-
-            return GLib.SOURCE_REMOVE
-
-        if self.challenger:
-            # Wait page is exited to unlock and load next pending challenger (if exists)
-            GLib.idle_add(do_next)
-        else:
-            self.exited = True
-            self.lock = False
-
-        logger.debug('Page closed')
-
-    def connect_signal(self, *args):
-        handler_id = self.connect(*args)
-        self.handlers_ids.append(handler_id)
-
-    def connect_webview_signal(self, *args):
-        handler_id = self.webkit_webview.connect(*args)
-        self.handlers_webview_ids.append(handler_id)
-
-    def disconnect_all_signals(self):
-        for handler_id in self.handlers_ids:
-            self.disconnect(handler_id)
-
-        self.handlers_ids = []
-
-        for handler_id in self.handlers_webview_ids:
-            self.webkit_webview.disconnect(handler_id)
-
-        self.handlers_webview_ids = []
-
-    def exit(self):
-        if self.window.page != self.props.tag:
-            # Page has already been popped or has never been pushed (no challenge)
-            # No need to wait `hidden` event to flag it as exited
-            self.exited = True
-            return
-
-        self.exited_auto = True
-        self.window.navigationview.pop()
-
-    def load_page(self, uri=None, challenger=None, user_agent=None, auto_load_images=True):
-        if self.lock or not self.exited:
-            # Already in use or page exiting is not ended (pop animation not ended)
-            return False
-
-        self.lock = True
-        self.exited = False
-        self.exited_auto = False
-
-        self.webkit_webview.get_settings().set_user_agent(user_agent or self.user_agent)
-        self.webkit_webview.get_settings().set_auto_load_images(auto_load_images)
-
-        self.challenger = challenger
-        if self.challenger:
-            self.connect_webview_signal('load-changed', self.challenger.on_load_changed)
-            self.connect_webview_signal('load-failed', self.challenger.on_load_failed)
-            self.connect_webview_signal('notify::title', self.challenger.on_title_changed)
-            uri = self.challenger.url
-
-        logger.debug('Load page %s', uri)
-
-        GLib.idle_add(self.webkit_webview.load_uri, uri)
-
-        return True
-
-    def on_hidden(self, _page):
-        self.exited = True
-
-        if self.challenger and self.challenger.error:
-            # Cancel all pending challengers with same URL if challenge was not completed
-            for challenger in self.challengers.copy():
-                if challenger.url == self.challenger.url:
-                    self.challengers.remove(challenger)
-                    challenger.cancel()
-                    break
-
-        if not self.exited_auto:
-            if self.challenger:
-                # Webview has been left via a user interaction (back button, <ESC> key)
-                self.challenger.cancel()
-            else:
-                self.emit('cancelled')
-
-        if not self.exited_auto:
-            self.close_page()
-
-    def pop_challenger(self):
-        if not self.challengers:
-            return
-
-        with self.concurrent_lock:
-            if self.load_page(challenger=self.challengers[0]):
-                self.challengers.popleft()
-
-    def push_challenger(self, challenger):
-        with self.concurrent_lock:
-            self.challengers.append(challenger)
-
-        self.pop_challenger()
-
-    def show(self):
-        GLib.idle_add(self.window.navigationview.push, self)
-
-
-class CompleteChallenge:
-    """Allows user to complete a browser challenge using the Webview
-
-    Several calls to this decorator can be concurrent. But only one will be honored at a time.
-    """
-
-    ALLOWED_METHODS = (
-        'get_manga_data',
-        'get_manga_chapter_data',
-        'get_manga_chapter_page_image',
-        'get_latest_updates',
-        'get_most_populars',
-        'search',
-    )
-
-    def __call__(self, func):
-        assert func.__name__ in self.ALLOWED_METHODS, f'@{self.__class__.__name__} decorator is not allowed on method `{func.__name__}`'
-
-        def wrapper(*args, **kwargs):
-            if func.__name__ not in self.ALLOWED_METHODS:
-                logger.error('@%s decorator is not allowed on method `%s`', self.__class__.__name__, func.__name__)
-                return func(*args, **kwargs)
-
-            server = args[0]
-            url = server.bypass_cf_url or server.base_url
-
-            if not server.has_cf and not server.has_captcha:
-                return func(*args, **kwargs)
-
-            # Test CF challenge cookie
-            if server.has_cf and not server.has_captcha:
-                if server.session is None:
-                    # Try loading a previous session
-                    server.load_session()
-
-                if server.session:
-                    logger.debug(f'{server.id}: Previous session found')
-
-                    # Locate CF challenge cookie
-                    cf_cookie_found = False
-                    for cookie in get_session_cookies(server.session):
-                        if cookie.name == 'cf_clearance':
-                            logger.debug(f'{server.id}: Session has CF challenge cookie')
-                            cf_cookie_found = True
-                            break
-
-                    if cf_cookie_found:
-                        # Check session validity
-                        logger.debug(f'{server.id}: Checking session...')
-                        r = server.session_get(url)
-                        if r.ok:
-                            logger.debug(f'{server.id}: Session OK')
-                            return func(*args, **kwargs)
-
-                        logger.debug(f'{server.id}: Session KO ({r.status_code})')
-                    else:
-                        logger.debug(f'{server.id}: Session has no CF challenge cookie')
-
-            webview = Gio.Application.get_default().window.webview
-            challenger = Challenger(server, webview, func.__name__)
-            webview.push_challenger(challenger)
-
-            while not challenger.done and challenger.error is None:
-                time.sleep(1)
-
-            if challenger.error:
-                logger.info(challenger.error)
-                raise ChallengerError()
-            else:
-                return func(*args, **kwargs)
-
-        return wrapper
-
-
-class Challenger:
-    def __init__(self, server, webview, context):
-        self.server = server
-        self.webview = webview
-        self.context = context
-        self.url = self.server.bypass_cf_url or self.server.base_url
-
-        self.cf_reload_count = 0
-        self.done = False
-        self.error = None
-        self.last_load_event = None
-
-    def cancel(self):
-        self.error = f'Challenge completion aborted: {self.server.id}'
-
-    def monitor_challenge(self):
-        # Detect captcha via JavaScript in current page
-        # - Cloudflare challenge
-        # - DDoS-Guard
-        # - Google ReCAPTCHA
-        # - AreYouHuman2 (2/3 images to identify)
-        # - Challange (browser identification, no user interaction)
-        #
-        # - A captcha is detected: change title to 'cf_captcha', 're_captcha',...
-        # - No challenge found: change title to 'ready'
-        # - An error occurs during challenge: change title to 'error'
-        js = """
-            let intervalID = setInterval(() => {
-                if (document.readyState === 'loading') {
-                    return;
-                }
-
-                if (document.getElementById('challenge-error-title')) {
-                    // CF error: Browser is outdated?
-                    document.title = 'error';
-                    clearInterval(intervalID);
-                }
-                else if (document.querySelector('.main-wrapper[role="main"] .main-content') || document.querySelector('.ray-id')) {
-                    document.title = 'cf_captcha';
-                }
-                else if (document.querySelector('#request-info')) {
-                    document.title = 'ddg_captcha';
-                }
-                else if (document.querySelector('.g-recaptcha') && !document.querySelector('form .g-recaptcha')) {
-                    // Google reCAPTCHA
-                    // Not in a form to avoid false positives (login form for ex.)
-                    document.title = 're_captcha';
-                }
-                else if (document.querySelector('#formVerify')) {
-                    document.title = 'ayh2_captcha';
-                }
-                else if (document.querySelector('script[src*="challange"]')) {
-                    document.title = 'challange_captcha';
-                }
-                else {
-                    document.title = 'ready';
-                    clearInterval(intervalID);
-                }
-            }, 100);
-        """
-        self.webview.webkit_webview.evaluate_javascript(js, -1)
-
-    def on_load_changed(self, _webkit_webview, event):
-        logger.debug(f'Load changed: {event} {self.webview.webkit_webview.get_uri()}')
-
-        if event != WebKit.LoadEvent.REDIRECTED and '__cf_chl_tk' in self.webview.webkit_webview.get_uri():
-            # Challenge has been passed
-            self.webview.title.set_title(_('Please wait…'))
-
-            # Disable images auto-load
-            logger.debug('Disable images automatic loading')
-            self.webview.webkit_webview.get_settings().set_auto_load_images(False)
-
-        elif event == WebKit.LoadEvent.COMMITTED or \
-                (event == WebKit.LoadEvent.FINISHED and self.last_load_event != WebKit.LoadEvent.COMMITTED):
-            # Normally, COMMITTED (2) event is received and then FINISHED (3) event.
-            # The challenge can be monitored as soon as COMMITTED event is emitted,
-            # but sometimes it isn't, so we have to wait for FINISHED event.
-            self.monitor_challenge()
-
-        self.last_load_event = event
-
-    def on_load_failed(self, _webkit_webview, _event, uri, _gerror):
-        self.error = f'Challenge completion error: failed to load URI {uri}'
-
-        self.webview.exit()
-        self.webview.close_page()
-
-    def on_title_changed(self, _webkit_webview, _title):
-        title = self.webview.webkit_webview.props.title
-        logger.debug(f'Title changed: {title}')
-
-        if title == 'error':
-            # CF error message detected
-            # settings or a features related?
-            self.error = 'CF challenge completion error'
-            self.webview.exit()
-            self.webview.close_page()
-            return
-
-        if title.endswith('_captcha'):
-            if title == 'cf_captcha':
-                self.cf_reload_count += 1
-            if self.cf_reload_count > CF_RELOAD_MAX:
-                self.error = 'CF challenge completion error: max reload exceeded'
-                self.webview.exit()
-                self.webview.close_page()
-                return
-
-            if title == 'cf_captcha':
-                logger.debug(f'{self.server.id}: CF captcha detected, try #{self.cf_reload_count}')
-            elif title == 'ddg_captcha':
-                logger.debug(f'{self.server.id}: DDoS-Guard detected')
-            elif title == 're_captcha':
-                logger.debug(f'{self.server.id}: ReCAPTCHA detected')
-            elif title == 'ayh2_captcha':
-                logger.debug(f'{self.server.id}: AreYouHuman2 detected')
-            elif title == 'challange_captcha':
-                logger.debug(f'{self.server.id}: Challange detected')
-
-            # Show webview, user must complete a CAPTCHA
-            if self.webview.window.page != self.webview.props.tag:
-                self.webview.title.set_title(_('Please complete CAPTCHA'))
-                self.webview.title.set_subtitle(self.server.name)
-                self.webview.show()
-
-        if title != 'ready':
-            return
-
-        # Challenge has been passed and page is loaded
-        # Webview should not be closed, we need to store cookies first
-        self.webview.exit()
-
-        logger.debug(f'{self.server.id}: Page loaded, getting cookies...')
-        self.webview.network_session.get_cookie_manager().get_cookies(
-            self.url, None, self.on_get_cookies_finished, None
-        )
-
-    def on_get_cookies_finished(self, cookie_manager, result, _user_data):
-        if self.server.http_client == 'requests':
-            self.server.session = requests.Session()
-
-        elif crequests is not None and self.server.http_client == 'curl_cffi':
-            self.server.session = crequests.Session(
-                allow_redirects=True,
-                impersonate='chrome',
-                timeout=(REQUESTS_TIMEOUT, REQUESTS_TIMEOUT * 2)
-            )
-
-        else:
-            self.error = f'{self.server.id}: Failed to copy Webview cookies in session (no HTTP client found)'
-            self.webview.close_page()
-            return
-
-        # Set default headers
-        self.server.session.headers.update(self.server.headers or {'User-Agent': self.webview.user_agent})
-
-        # Copy libsoup cookies in session cookies jar
-        cf_cookie_found = False
-        rcookies = []
-        for cookie in cookie_manager.get_cookies_finish(result):
-            rcookies.append(requests.cookies.create_cookie(
-                name=cookie.get_name(),
-                value=cookie.get_value(),
-                domain=cookie.get_domain(),
-                path=cookie.get_path(),
-                expires=cookie.get_expires().to_unix() if cookie.get_expires() else None,
-                rest={'HttpOnly': cookie.get_http_only()},
-                secure=cookie.get_secure(),
-            ))
-            if cookie.get_name() == 'cf_clearance':
-                cf_cookie_found = True
-
-        if not cf_cookie_found:
-            # Server don't used Cloudflare (temporarily or not at all)
-            # Create a fake `cf_clearance` cookie, so as not to try to detect the challenge next time
-            rcookies.append(requests.cookies.create_cookie(
-                name='cf_clearance',
-                value='74k3',
-                domain=urlsplit(self.url).netloc,
-                path='/',
-            ))
-
-        for rcookie in rcookies:
-            if self.server.http_client == 'requests':
-                self.server.session.cookies.set_cookie(rcookie)
-
-            elif self.server.http_client == 'curl_cffi':
-                self.server.session.cookies.jar.set_cookie(rcookie)
-
-        logger.debug(f'{self.server.id}: Webview cookies successfully copied in session')
-        self.server.save_session()
-
-        self.done = True
-        self.webview.close_page()
-
-
-def eval_js(code):
-    error = None
-    res = None
-    webview = Gio.Application.get_default().window.webview
-
-    def load_page():
-        if not webview.load_page(uri='about:blank'):
-            return True
-
-        webview.connect_webview_signal('load-changed', on_load_changed)
-
-    def on_evaluate_javascript_finish(_webkit_webview, result, _user_data=None):
-        nonlocal error
-        nonlocal res
-
-        try:
-            js_result = webview.webkit_webview.evaluate_javascript_finish(result)
-        except GLib.GError:
-            error = 'Failed to eval JS code'
-        else:
-            if js_result.is_string():
-                res = js_result.to_string()
-
-            if res is None:
-                error = 'Failed to eval JS code'
-
-        webview.close_page()
-
-    def on_load_changed(_webkit_webview, event):
-        if event != WebKit.LoadEvent.FINISHED:
-            return
-
-        webview.webkit_webview.evaluate_javascript(code, -1, None, None, None, on_evaluate_javascript_finish)
-
-    GLib.timeout_add(100, load_page)
-
-    while res is None and error is None:
-        time.sleep(1)
-
-    if error:
-        logger.warning(error)
-        raise requests.exceptions.RequestException()
-
-    return res
-
-
-def get_page_html(url, user_agent=None, wait_js_code=None, with_cookies=False):
-    cookies = None
-    error = None
-    html = None
-    webview = Gio.Application.get_default().window.webview
-
-    def load_page():
-        if not webview.load_page(uri=url, user_agent=user_agent, auto_load_images=False):
-            return True
-
-        webview.connect_webview_signal('load-changed', on_load_changed)
-        webview.connect_webview_signal('load-failed', on_load_failed)
-        webview.connect_webview_signal('notify::title', on_title_changed)
-
-    def on_get_cookies_finished(cookie_manager, result, _user_data):
-        nonlocal cookies
-
-        rcookies = []
-        # Get libsoup cookies
-        for cookie in cookie_manager.get_cookies_finish(result):
-            rcookie = requests.cookies.create_cookie(
-                name=cookie.get_name(),
-                value=cookie.get_value(),
-                domain=cookie.get_domain(),
-                path=cookie.get_path(),
-                expires=cookie.get_expires().to_unix() if cookie.get_expires() else None,
-                rest={'HttpOnly': cookie.get_http_only()},
-                secure=cookie.get_secure(),
-            )
-            rcookies.append(rcookie)
-
-        cookies = rcookies
-
-        webview.close_page()
-
-    def on_get_html_finish(_webkit_webview, result, _user_data=None):
-        nonlocal error
-        nonlocal html
-
-        js_result = webview.webkit_webview.evaluate_javascript_finish(result)
-        if js_result:
-            html = js_result.to_string()
-
-        if html is not None:
-            if with_cookies:
-                logger.debug('Page loaded, getting cookies...')
-                webview.network_session.get_cookie_manager().get_cookies(url, None, on_get_cookies_finished, None)
-            else:
-                webview.close_page()
-        else:
-            error = f'Failed to get page html: {url}'
-            webview.close_page()
-
-    def on_load_changed(_webkit_webview, event):
-        if event != WebKit.LoadEvent.FINISHED:
-            return
-
-        if wait_js_code:
-            # Wait that everything needed has been loaded
-            webview.webkit_webview.evaluate_javascript(wait_js_code, -1)
-        else:
-            webview.webkit_webview.evaluate_javascript('document.documentElement.outerHTML', -1, None, None, None, on_get_html_finish)
-
-    def on_load_failed(_webkit_webview, _event, _uri, _gerror):
-        nonlocal error
-
-        error = f'Failed to load page: {url}'
-        webview.close_page()
-
-    def on_title_changed(_webkit_webview, _title):
-        nonlocal error
-
-        if webview.webkit_webview.props.title == 'ready':
-            # Everything we need has been loaded, we can retrieve page HTML
-            webview.webkit_webview.evaluate_javascript('document.documentElement.outerHTML', -1, None, None, None, on_get_html_finish)
-
-        elif webview.webkit_webview.props.title == 'abort':
-            error = f'Failed to get page html: {url}'
-            webview.close_page()
-
-    GLib.timeout_add(100, load_page)
-
-    while (html is None or (with_cookies and cookies is None)) and error is None:
-        time.sleep(1)
-
-    if error:
-        logger.warning(error)
-        raise requests.exceptions.RequestException()
-
-    return html if not with_cookies else (html, cookies)
-
-
-def get_page_resources(url, paths, timeout=20, user_agent=None):
-    """
-    Returns all resources loaded by a page
-
-    :param url: Page URL
-    :param paths: List of paths to which resource URIs must match
-    :param timeout: Timeout in seconds (optional)
-    :param user_agent: User agent (optional)
-    """
-
-    data = None
-    error = None
-    resources = {}
-    ts_start = None
-    webview = Gio.Application.get_default().window.webview
-
-    def load_page():
-        nonlocal ts_start
-
-        if not webview.load_page(uri=url, user_agent=user_agent, auto_load_images=False):
-            return True
-
-        ts_start = time.time()
-
-        webview.connect_webview_signal('resource-load-started', on_resource_load_started)
-        webview.connect_webview_signal('load-changed', on_load_changed)
-        webview.connect_webview_signal('load-failed', on_load_failed)
-
-    def on_load_changed(_webkit_webview, event):
-        nonlocal data
-        if event == WebKit.LoadEvent.FINISHED:
-            data = resources
-
-    def on_resource_load_started(_webkit_webview, resource, request):
-        nonlocal resources
-
-        uri = request.get_uri()
-        if uri in resources:
-            return
-
-        found = False
-        for path in paths:
-            if path in uri:
-                found = True
-                break
-
-        if not found:
-            return
-
-        resources[uri] = dict(
-            uri=uri,
-            resource=resource,
-            request=request
-        )
-
-    def on_load_failed(_webkit_webview, _event, _uri, _gerror):
-        nonlocal error
-
-        error = f'Failed to load page: {url}'
-
-    GLib.timeout_add(100, load_page)
-
-    while (not ts_start or time.time() - ts_start < timeout) and data is None and error is None:
-        time.sleep(1)
-
-    if time.time() - ts_start > timeout:
-        error = f'Failed to load page (timeout): {url}'
-
-    webview.close_page()
-
-    if error:
-        logger.warning(error)
-        raise requests.exceptions.RequestException()
-
-    return list(data.values())
-
-
-def get_tracker_access_token(url, app_redirect_url, user_agent=None):
-    """Use webview to request a client access token to a tracker
-
-    User will be asked to approve client permission.
-    If user is not logged in, it will first be taken to the standard login page.
-
-    :param url: Authorization request URL
-    :param app_redirect_url: App redirection URL
-    :param user_agent: User agent (optional)
-    """
-
-    error = None
-    redirect_url = None
-    webview = Gio.Application.get_default().window.webview
-
-    def load_page():
-        if not webview.load_page(uri=url, user_agent=user_agent):
-            return False
-
-        # We assume that this function is always called from preferences
-        # Preferences dialog must be closed before opening webview page
-        GLib.idle_add(webview.window.preferences.force_close)
-
-        webview.connect_signal('cancelled', on_cancelled)
-        webview.connect_webview_signal('load-changed', on_load_changed)
-        webview.connect_webview_signal('load-failed', on_load_failed)
-        webview.show()
-
-        return True
-
-    def on_cancelled(self):
-        nonlocal error
-        error = 'cancelled'
-
-    def on_load_changed(_webkit_webview, event):
-        nonlocal redirect_url
-
-        uri = _webkit_webview.get_uri()
-        if event == WebKit.LoadEvent.REDIRECTED and uri.startswith(app_redirect_url):
-            redirect_url = uri
-            webview.exit()
-            webview.close_page()
-
-    def on_load_failed(_webkit_webview, _event, _uri, _gerror):
-        nonlocal error
-        error = 'failed'
-        webview.exit()
-        webview.close_page()
-
-    if not load_page():
-        error = 'locked'
-
-    while redirect_url is None and error is None:
-        time.sleep(1)
-
-    if error != 'locked':
-        # We assume that this function is always called from preferences
-        # Preferences dialog must be re-opened after closing webview page
-        GLib.idle_add(webview.window.preferences.show)
-
-    return redirect_url, error
