summaryrefslogtreecommitdiffstatshomepage
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/hircine/__init__.py2
-rw-r--r--src/hircine/api/filters.py69
-rw-r--r--src/hircine/api/inputs.py10
-rw-r--r--src/hircine/api/mutation/resolvers.py9
-rw-r--r--src/hircine/api/query/__init__.py6
-rw-r--r--src/hircine/api/query/resolvers.py29
-rw-r--r--src/hircine/api/responses.py30
-rw-r--r--src/hircine/api/sort.py11
-rw-r--r--src/hircine/api/types.py34
-rw-r--r--src/hircine/db/models.py41
-rw-r--r--src/hircine/db/ops.py8
-rw-r--r--src/hircine/enums.py8
-rw-r--r--src/hircine/plugins/__init__.py3
-rw-r--r--src/hircine/plugins/scrapers/anchira.py14
-rw-r--r--src/hircine/plugins/scrapers/schale_network.py82
-rw-r--r--src/hircine/scanner.py11
16 files changed, 296 insertions, 71 deletions
diff --git a/src/hircine/__init__.py b/src/hircine/__init__.py
index 38b969d..935742b 100644
--- a/src/hircine/__init__.py
+++ b/src/hircine/__init__.py
@@ -1 +1 @@
-codename = "Satanic Satyr"
+codename = "Profligate Pixie"
diff --git a/src/hircine/api/filters.py b/src/hircine/api/filters.py
index ab44cf9..7ed5649 100644
--- a/src/hircine/api/filters.py
+++ b/src/hircine/api/filters.py
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
-from typing import Generic, List, Optional, TypeVar
+from typing import Generic, Optional, TypeVar
import strawberry
from sqlalchemy import and_, func, or_, select
@@ -7,7 +7,7 @@ from strawberry import UNSET
import hircine.db
from hircine.db.models import ComicTag
-from hircine.enums import Category, Censorship, Language, Rating
+from hircine.enums import Category, Censorship, Language, Operator, Rating
T = TypeVar("T")
@@ -28,11 +28,23 @@ class Matchable(ABC):
@strawberry.input
+class CountFilter:
+ operator: Optional[Operator] = Operator.EQUAL
+ value: int
+
+ def include(self, column, sql):
+ return sql.where(self.operator.value(column, self.value))
+
+ def exclude(self, column, sql):
+ return sql.where(~self.operator.value(column, self.value))
+
+
+@strawberry.input
class AssociationFilter(Matchable):
- any: Optional[List[int]] = strawberry.field(default_factory=lambda: None)
- all: Optional[List[int]] = strawberry.field(default_factory=lambda: None)
- exact: Optional[List[int]] = strawberry.field(default_factory=lambda: None)
- empty: Optional[bool] = None
+ any: Optional[list[int]] = strawberry.field(default_factory=lambda: None)
+ all: Optional[list[int]] = strawberry.field(default_factory=lambda: None)
+ exact: Optional[list[int]] = strawberry.field(default_factory=lambda: None)
+ count: Optional[CountFilter] = UNSET
def _exists(self, condition):
# The property.primaryjoin expression specifies the primary join path
@@ -71,12 +83,6 @@ class AssociationFilter(Matchable):
def _where_not_all_exist(self, sql):
return sql.where(~self._all_exist(self.all))
- def _empty(self):
- if self.empty:
- return ~self._exists(True)
- else:
- return self._exists(True)
-
def _count_of(self, column):
return (
select(func.count(column))
@@ -117,8 +123,8 @@ class AssociationFilter(Matchable):
elif self.all == []:
sql = sql.where(False)
- if self.empty is not None:
- sql = sql.where(self._empty())
+ if self.count:
+ sql = self.count.include(self.count_column, sql)
if self.exact is not None:
sql = sql.where(self._exact())
@@ -134,8 +140,8 @@ class AssociationFilter(Matchable):
if self.all:
sql = self._where_not_all_exist(sql)
- if self.empty is not None:
- sql = sql.where(~self._empty())
+ if self.count:
+ sql = self.count.exclude(self.count_column, sql)
if self.exact is not None:
sql = sql.where(~self._exact())
@@ -160,8 +166,14 @@ class Root:
column = getattr(self._model, field, None)
+ # count columns are historically singular, so we need this hack
+ singular_field = field[:-1]
+ count_column = getattr(self._model, f"{singular_field}_count", None)
+
if issubclass(type(matcher), Matchable):
matcher.column = column
+ matcher.count_column = count_column
+
if not negate:
sql = matcher.include(sql)
else:
@@ -213,6 +225,17 @@ class StringFilter(Matchable):
@strawberry.input
+class BasicCountFilter(Matchable):
+ count: CountFilter
+
+ def include(self, sql):
+ return self.count.include(self.count_column, sql)
+
+ def exclude(self, sql):
+ return self.count.exclude(self.count_column, sql)
+
+
+@strawberry.input
class TagAssociationFilter(AssociationFilter):
"""
Tags need special handling since their IDs are strings instead of numbers.
@@ -220,9 +243,9 @@ class TagAssociationFilter(AssociationFilter):
we unpack the database IDs from the input IDs.
"""
- any: Optional[List[str]] = strawberry.field(default_factory=lambda: None)
- all: Optional[List[str]] = strawberry.field(default_factory=lambda: None)
- exact: Optional[List[str]] = strawberry.field(default_factory=lambda: None)
+ any: Optional[list[str]] = strawberry.field(default_factory=lambda: None)
+ all: Optional[list[str]] = strawberry.field(default_factory=lambda: None)
+ exact: Optional[list[str]] = strawberry.field(default_factory=lambda: None)
def where(self, id):
try:
@@ -252,7 +275,7 @@ class TagAssociationFilter(AssociationFilter):
@strawberry.input
class Filter(Matchable, Generic[T]):
- any: Optional[List["T"]] = strawberry.field(default_factory=lambda: None)
+ any: Optional[list["T"]] = strawberry.field(default_factory=lambda: None)
empty: Optional[bool] = None
def _empty(self):
@@ -314,24 +337,28 @@ class ArchiveFilter(Root):
@strawberry.input
class ArtistFilter(Root):
name: Optional[StringFilter] = UNSET
+ comics: Optional[BasicCountFilter] = UNSET
@hircine.db.model("Character")
@strawberry.input
class CharacterFilter(Root):
name: Optional[StringFilter] = UNSET
+ comics: Optional[BasicCountFilter] = UNSET
@hircine.db.model("Circle")
@strawberry.input
class CircleFilter(Root):
name: Optional[StringFilter] = UNSET
+ comics: Optional[BasicCountFilter] = UNSET
@hircine.db.model("Namespace")
@strawberry.input
class NamespaceFilter(Root):
name: Optional[StringFilter] = UNSET
+ tags: Optional[BasicCountFilter] = UNSET
@hircine.db.model("Tag")
@@ -339,9 +366,11 @@ class NamespaceFilter(Root):
class TagFilter(Root):
name: Optional[StringFilter] = UNSET
namespaces: Optional[AssociationFilter] = UNSET
+ comics: Optional[BasicCountFilter] = UNSET
@hircine.db.model("World")
@strawberry.input
class WorldFilter(Root):
name: Optional[StringFilter] = UNSET
+ comics: Optional[BasicCountFilter] = UNSET
diff --git a/src/hircine/api/inputs.py b/src/hircine/api/inputs.py
index 38e17e4..039c211 100644
--- a/src/hircine/api/inputs.py
+++ b/src/hircine/api/inputs.py
@@ -1,6 +1,6 @@
import datetime
from abc import ABC, abstractmethod
-from typing import List, Optional, Type
+from typing import Optional
import strawberry
from sqlalchemy.orm.util import identity_key
@@ -72,7 +72,7 @@ class Fetchable(ABC):
Additionally, fetched items can be "constrained" to enforce API rules.
"""
- _model: Type[Base]
+ _model: type[Base]
@abstractmethod
async def fetch(self, ctx: MutationContext):
@@ -151,7 +151,7 @@ class Input(FetchableID):
@strawberry.input
class InputList(FetchableID):
- ids: List[int]
+ ids: list[int]
async def fetch(self, ctx: MutationContext):
if not self.ids:
@@ -271,7 +271,7 @@ class WorldsUpdateInput(UpdateInputList):
@strawberry.input
class ComicTagsUpdateInput(UpdateInputList):
- ids: List[str] = strawberry.field(default_factory=lambda: [])
+ ids: list[str] = strawberry.field(default_factory=lambda: [])
@classmethod
def parse_input(cls, id):
@@ -334,7 +334,7 @@ class UpsertOptions:
@strawberry.input
class UpsertInputList(FetchableName):
- names: List[str] = strawberry.field(default_factory=lambda: [])
+ names: list[str] = strawberry.field(default_factory=lambda: [])
options: Optional[UpsertOptions] = UNSET
async def fetch(self, ctx: MutationContext):
diff --git a/src/hircine/api/mutation/resolvers.py b/src/hircine/api/mutation/resolvers.py
index 1a0cd45..b3587f7 100644
--- a/src/hircine/api/mutation/resolvers.py
+++ b/src/hircine/api/mutation/resolvers.py
@@ -1,6 +1,5 @@
from datetime import datetime, timezone
from pathlib import Path
-from typing import List
from strawberry import UNSET
@@ -130,7 +129,7 @@ def update_attr(obj, field, value, mode):
setattr(obj, field, value)
-async def _update(ids: List[int], modelcls, input, successcls):
+async def _update(ids: list[int], modelcls, input, successcls):
multiple = len(ids) > 1
async with db.session() as s:
@@ -163,21 +162,21 @@ async def _update(ids: List[int], modelcls, input, successcls):
def update(modelcls):
- async def inner(ids: List[int], input: update_input_cls(modelcls)):
+ async def inner(ids: list[int], input: update_input_cls(modelcls)):
return await _update(ids, modelcls, input, UpdateSuccess)
return inner
def upsert(modelcls):
- async def inner(ids: List[int], input: upsert_input_cls(modelcls)):
+ async def inner(ids: list[int], input: upsert_input_cls(modelcls)):
return await _update(ids, modelcls, input, UpsertSuccess)
return inner
def delete(modelcls, post_delete=None):
- async def inner(ids: List[int]):
+ async def inner(ids: list[int]):
async with db.session() as s:
objects, missing = await ops.get_all(s, modelcls, ids)
if missing:
diff --git a/src/hircine/api/query/__init__.py b/src/hircine/api/query/__init__.py
index 2e07c71..37b22df 100644
--- a/src/hircine/api/query/__init__.py
+++ b/src/hircine/api/query/__init__.py
@@ -1,5 +1,3 @@
-from typing import List
-
import strawberry
import hircine.api.responses as rp
@@ -24,6 +22,7 @@ from .resolvers import (
every,
scrape_comic,
single,
+ statistics,
)
@@ -42,7 +41,7 @@ class Query:
circle: rp.CircleResponse = query(single(models.Circle))
circles: FilterResult[Circle] = query(every(models.Circle))
comic: rp.ComicResponse = query(single(models.Comic, full=True))
- comic_scrapers: List[ComicScraper] = query(comic_scrapers)
+ comic_scrapers: list[ComicScraper] = query(comic_scrapers)
comic_tags: FilterResult[ComicTag] = query(comic_tags)
comics: FilterResult[Comic] = query(every(models.Comic))
namespace: rp.NamespaceResponse = query(single(models.Namespace))
@@ -52,3 +51,4 @@ class Query:
world: rp.WorldResponse = query(single(models.World))
worlds: FilterResult[World] = query(every(models.World))
scrape_comic: rp.ScrapeComicResponse = query(scrape_comic)
+ statistics: rp.Statistics = query(statistics)
diff --git a/src/hircine/api/query/resolvers.py b/src/hircine/api/query/resolvers.py
index 6609cc1..389a200 100644
--- a/src/hircine/api/query/resolvers.py
+++ b/src/hircine/api/query/resolvers.py
@@ -10,10 +10,13 @@ import hircine.plugins as plugins
from hircine.api.filters import Input as FilterInput
from hircine.api.inputs import Pagination
from hircine.api.responses import (
+ ComicTotals,
IDNotFoundError,
ScraperError,
ScraperNotAvailableError,
ScraperNotFoundError,
+ Statistics,
+ Totals,
)
from hircine.api.sort import Input as SortInput
from hircine.api.types import (
@@ -144,3 +147,29 @@ async def scrape_comic(id: int, scraper: str):
)
except ScrapeError as e:
return ScraperError(error=str(e))
+
+
+async def statistics():
+ async with db.session() as s:
+ total = Totals(
+ archives=await ops.count(s, models.Archive),
+ artists=await ops.count(s, models.Artist),
+ characters=await ops.count(s, models.Character),
+ circles=await ops.count(s, models.Circle),
+ comic=ComicTotals(
+ artists=await ops.count(s, models.ComicArtist),
+ characters=await ops.count(s, models.ComicCharacter),
+ circles=await ops.count(s, models.ComicCircle),
+ tags=await ops.count(s, models.ComicTag),
+ worlds=await ops.count(s, models.ComicWorld),
+ ),
+ comics=await ops.count(s, models.Comic),
+ images=await ops.count(s, models.Image),
+ namespaces=await ops.count(s, models.Namespace),
+ pages=await ops.count(s, models.Page),
+ scrapers=len(plugins.get_scrapers()),
+ tags=await ops.count(s, models.Tag),
+ worlds=await ops.count(s, models.World),
+ )
+
+ return Statistics(total=total)
diff --git a/src/hircine/api/responses.py b/src/hircine/api/responses.py
index 99d5113..883705b 100644
--- a/src/hircine/api/responses.py
+++ b/src/hircine/api/responses.py
@@ -147,6 +147,36 @@ class ScraperNotAvailableError(Error):
return f"Scraper {self.scraper} not available for comic ID {self.comic_id}"
+@strawberry.type
+class ComicTotals:
+ artists: int
+ characters: int
+ circles: int
+ tags: int
+ worlds: int
+
+
+@strawberry.type
+class Totals:
+ archives: int
+ artists: int
+ characters: int
+ circles: int
+ comics: int
+ comic: ComicTotals
+ images: int
+ namespaces: int
+ pages: int
+ scrapers: int
+ tags: int
+ worlds: int
+
+
+@strawberry.type
+class Statistics:
+ total: Totals
+
+
AddComicResponse = Annotated[
Union[
AddComicSuccess,
diff --git a/src/hircine/api/sort.py b/src/hircine/api/sort.py
index 17043a6..a4ccaf1 100644
--- a/src/hircine/api/sort.py
+++ b/src/hircine/api/sort.py
@@ -22,6 +22,10 @@ class ComicSort(enum.Enum):
DATE = strawberry.enum_value(models.Comic.date)
CREATED_AT = strawberry.enum_value(models.Comic.created_at)
UPDATED_AT = strawberry.enum_value(models.Comic.updated_at)
+ ARTIST_COUNT = strawberry.enum_value(models.Comic.artist_count)
+ CHARACTER_COUNT = strawberry.enum_value(models.Comic.character_count)
+ CIRCLE_COUNT = strawberry.enum_value(models.Comic.circle_count)
+ WORLD_COUNT = strawberry.enum_value(models.Comic.world_count)
TAG_COUNT = strawberry.enum_value(models.Comic.tag_count)
PAGE_COUNT = strawberry.enum_value(models.Comic.page_count)
RANDOM = "Random"
@@ -41,6 +45,7 @@ class ArtistSort(enum.Enum):
NAME = strawberry.enum_value(models.Artist.name)
CREATED_AT = strawberry.enum_value(models.Artist.created_at)
UPDATED_AT = strawberry.enum_value(models.Artist.updated_at)
+ COMIC_COUNT = strawberry.enum_value(models.Artist.comic_count)
RANDOM = "Random"
@@ -49,6 +54,7 @@ class CharacterSort(enum.Enum):
NAME = strawberry.enum_value(models.Character.name)
CREATED_AT = strawberry.enum_value(models.Character.created_at)
UPDATED_AT = strawberry.enum_value(models.Character.updated_at)
+ COMIC_COUNT = strawberry.enum_value(models.Character.comic_count)
RANDOM = "Random"
@@ -57,6 +63,7 @@ class CircleSort(enum.Enum):
NAME = strawberry.enum_value(models.Circle.name)
CREATED_AT = strawberry.enum_value(models.Circle.created_at)
UPDATED_AT = strawberry.enum_value(models.Circle.updated_at)
+ COMIC_COUNT = strawberry.enum_value(models.Circle.comic_count)
RANDOM = "Random"
@@ -66,6 +73,7 @@ class NamespaceSort(enum.Enum):
NAME = strawberry.enum_value(models.Namespace.name)
CREATED_AT = strawberry.enum_value(models.Namespace.created_at)
UPDATED_AT = strawberry.enum_value(models.Namespace.updated_at)
+ TAG_COUNT = strawberry.enum_value(models.Namespace.tag_count)
RANDOM = "Random"
@@ -74,6 +82,8 @@ class TagSort(enum.Enum):
NAME = strawberry.enum_value(models.Tag.name)
CREATED_AT = strawberry.enum_value(models.Tag.created_at)
UPDATED_AT = strawberry.enum_value(models.Tag.updated_at)
+ COMIC_COUNT = strawberry.enum_value(models.Tag.comic_count)
+ NAMESPACE_COUNT = strawberry.enum_value(models.Tag.namespace_count)
RANDOM = "Random"
@@ -82,6 +92,7 @@ class WorldSort(enum.Enum):
NAME = strawberry.enum_value(models.World.name)
CREATED_AT = strawberry.enum_value(models.World.created_at)
UPDATED_AT = strawberry.enum_value(models.World.updated_at)
+ COMIC_COUNT = strawberry.enum_value(models.World.comic_count)
RANDOM = "Random"
diff --git a/src/hircine/api/types.py b/src/hircine/api/types.py
index bbd13fa..68b2ccc 100644
--- a/src/hircine/api/types.py
+++ b/src/hircine/api/types.py
@@ -1,5 +1,5 @@
import datetime
-from typing import Generic, List, Optional, TypeVar
+from typing import Generic, Optional, TypeVar
import strawberry
@@ -74,7 +74,7 @@ class MixinModifyDates(MixinCreatedAt):
@strawberry.type
class FilterResult(Generic[T]):
count: int
- edges: List["T"]
+ edges: list["T"]
@strawberry.type
@@ -94,8 +94,8 @@ class Archive(MixinName, MixinOrganized, Base):
@strawberry.type
class FullArchive(MixinCreatedAt, Archive):
- pages: List["Page"]
- comics: List["Comic"]
+ pages: list["Page"]
+ comics: list["Comic"]
mtime: datetime.datetime
def __init__(self, model):
@@ -143,11 +143,11 @@ class Comic(MixinFavourite, MixinOrganized, MixinBookmarked, Base):
rating: Optional[Rating]
category: Optional[Category]
censorship: Optional[Censorship]
- tags: List["ComicTag"]
- artists: List["Artist"]
- characters: List["Character"]
- circles: List["Circle"]
- worlds: List["World"]
+ tags: list["ComicTag"]
+ artists: list["Artist"]
+ characters: list["Character"]
+ circles: list["Circle"]
+ worlds: list["World"]
page_count: int
def __init__(self, model):
@@ -172,7 +172,7 @@ class Comic(MixinFavourite, MixinOrganized, MixinBookmarked, Base):
class FullComic(MixinModifyDates, Comic):
archive: "Archive"
url: Optional[str]
- pages: List["Page"]
+ pages: list["Page"]
direction: Direction
layout: Layout
@@ -196,7 +196,7 @@ class Tag(MixinName, Base):
@strawberry.type
class FullTag(Tag):
- namespaces: List["Namespace"]
+ namespaces: list["Namespace"]
def __init__(self, model):
super().__init__(model)
@@ -270,7 +270,7 @@ class ComicScraper:
@strawberry.type
class ScrapeComicResult:
data: "ScrapedComic"
- warnings: List[str] = strawberry.field(default_factory=lambda: [])
+ warnings: list[str] = strawberry.field(default_factory=lambda: [])
@strawberry.type
@@ -285,11 +285,11 @@ class ScrapedComic:
censorship: Optional[Censorship] = None
direction: Optional[Direction] = None
layout: Optional[Layout] = None
- tags: List[str] = strawberry.field(default_factory=lambda: [])
- artists: List[str] = strawberry.field(default_factory=lambda: [])
- characters: List[str] = strawberry.field(default_factory=lambda: [])
- circles: List[str] = strawberry.field(default_factory=lambda: [])
- worlds: List[str] = strawberry.field(default_factory=lambda: [])
+ tags: list[str] = strawberry.field(default_factory=lambda: [])
+ artists: list[str] = strawberry.field(default_factory=lambda: [])
+ characters: list[str] = strawberry.field(default_factory=lambda: [])
+ circles: list[str] = strawberry.field(default_factory=lambda: [])
+ worlds: list[str] = strawberry.field(default_factory=lambda: [])
@classmethod
def from_generator(cls, generator):
diff --git a/src/hircine/db/models.py b/src/hircine/db/models.py
index 575771b..5d1a59a 100644
--- a/src/hircine/db/models.py
+++ b/src/hircine/db/models.py
@@ -1,6 +1,6 @@
import os
from datetime import date, datetime, timezone
-from typing import List, Optional
+from typing import Optional
from sqlalchemy import (
DateTime,
@@ -104,12 +104,12 @@ class Archive(MixinID, MixinCreatedAt, MixinOrganized, Base):
cover_id: Mapped[int] = mapped_column(ForeignKey("image.id"))
cover: Mapped["Image"] = relationship(lazy="joined", innerjoin=True)
- pages: Mapped[List["Page"]] = relationship(
+ pages: Mapped[list["Page"]] = relationship(
back_populates="archive",
order_by="(Page.index)",
cascade="save-update, merge, expunge, delete, delete-orphan",
)
- comics: Mapped[List["Comic"]] = relationship(
+ comics: Mapped[list["Comic"]] = relationship(
back_populates="archive",
cascade="save-update, merge, expunge, delete, delete-orphan",
)
@@ -176,37 +176,37 @@ class Comic(
archive_id: Mapped[int] = mapped_column(ForeignKey("archive.id"))
archive: Mapped["Archive"] = relationship(back_populates="comics")
- pages: Mapped[List["Page"]] = relationship(order_by="(Page.index)")
+ pages: Mapped[list["Page"]] = relationship(order_by="(Page.index)")
page_count: Mapped[int]
- tags: Mapped[List["ComicTag"]] = relationship(
+ tags: Mapped[list["ComicTag"]] = relationship(
lazy="selectin",
cascade="save-update, merge, expunge, delete, delete-orphan",
passive_deletes=True,
)
- artists: Mapped[List["Artist"]] = relationship(
+ artists: Mapped[list["Artist"]] = relationship(
secondary="comicartist",
lazy="selectin",
order_by="(Artist.name, Artist.id)",
passive_deletes=True,
)
- characters: Mapped[List["Character"]] = relationship(
+ characters: Mapped[list["Character"]] = relationship(
secondary="comiccharacter",
lazy="selectin",
order_by="(Character.name, Character.id)",
passive_deletes=True,
)
- circles: Mapped[List["Circle"]] = relationship(
+ circles: Mapped[list["Circle"]] = relationship(
secondary="comiccircle",
lazy="selectin",
order_by="(Circle.name, Circle.id)",
passive_deletes=True,
)
- worlds: Mapped[List["World"]] = relationship(
+ worlds: Mapped[list["World"]] = relationship(
secondary="comicworld",
lazy="selectin",
order_by="(World.name, World.id)",
@@ -233,7 +233,7 @@ class Comic(
class Tag(MixinID, MixinModifyDates, MixinName, Base):
description: Mapped[Optional[str]]
- namespaces: Mapped[List["Namespace"]] = relationship(
+ namespaces: Mapped[list["Namespace"]] = relationship(
secondary="tagnamespaces",
passive_deletes=True,
order_by="(Namespace.sort_name, Namespace.name, Namespace.id)",
@@ -356,7 +356,10 @@ class ComicWorld(Base):
def defer_relationship_count(relationship, secondary=False):
- left, right = relationship.property.synchronize_pairs[0]
+ if secondary:
+ left, right = relationship.property.secondary_synchronize_pairs[0]
+ else:
+ left, right = relationship.property.synchronize_pairs[0]
return deferred(
select(func.count(right))
@@ -366,7 +369,23 @@ def defer_relationship_count(relationship, secondary=False):
)
+Comic.artist_count = defer_relationship_count(Comic.artists)
+Comic.character_count = defer_relationship_count(Comic.characters)
+Comic.circle_count = defer_relationship_count(Comic.circles)
Comic.tag_count = defer_relationship_count(Comic.tags)
+Comic.world_count = defer_relationship_count(Comic.worlds)
+
+Artist.comic_count = defer_relationship_count(Comic.artists, secondary=True)
+Character.comic_count = defer_relationship_count(Comic.characters, secondary=True)
+Circle.comic_count = defer_relationship_count(Comic.circles, secondary=True)
+Namespace.tag_count = defer_relationship_count(Tag.namespaces, secondary=True)
+Tag.comic_count = deferred(
+ select(func.count(ComicTag.tag_id))
+ .where(Tag.id == ComicTag.tag_id)
+ .scalar_subquery()
+)
+Tag.namespace_count = defer_relationship_count(Tag.namespaces)
+World.comic_count = defer_relationship_count(Comic.worlds, secondary=True)
@event.listens_for(Comic.pages, "bulk_replace")
diff --git a/src/hircine/db/ops.py b/src/hircine/db/ops.py
index 91c830d..8cc5ddc 100644
--- a/src/hircine/db/ops.py
+++ b/src/hircine/db/ops.py
@@ -1,7 +1,7 @@
import random
from collections import defaultdict
-from sqlalchemy import delete, func, null, select, text, tuple_
+from sqlalchemy import delete, func, literal_column, null, select, text, tuple_
from sqlalchemy.orm import contains_eager, undefer
from sqlalchemy.orm.util import identity_key
from strawberry import UNSET
@@ -204,3 +204,9 @@ async def delete_all(session, model, ids):
result = await session.execute(delete(model).where(model.id.in_(ids)))
return result.rowcount
+
+
+async def count(session, model):
+ sql = select(func.count(literal_column("1"))).select_from(model)
+
+ return (await session.execute(sql)).scalar_one()
diff --git a/src/hircine/enums.py b/src/hircine/enums.py
index 7f95f02..f267270 100644
--- a/src/hircine/enums.py
+++ b/src/hircine/enums.py
@@ -1,4 +1,5 @@
import enum
+import operator
import strawberry
@@ -57,6 +58,13 @@ class OnMissing(enum.Enum):
@strawberry.enum
+class Operator(enum.Enum):
+ GREATER_THAN = operator.gt
+ LOWER_THAN = operator.lt
+ EQUAL = operator.eq
+
+
+@strawberry.enum
class Language(enum.Enum):
AA = "Afar"
AB = "Abkhazian"
diff --git a/src/hircine/plugins/__init__.py b/src/hircine/plugins/__init__.py
index a1aea90..7bd3612 100644
--- a/src/hircine/plugins/__init__.py
+++ b/src/hircine/plugins/__init__.py
@@ -1,9 +1,8 @@
from importlib.metadata import entry_points
-from typing import Dict, Type
from hircine.scraper import Scraper
-scraper_registry: Dict[str, Type[Scraper]] = {}
+scraper_registry: dict[str, type[Scraper]] = {}
transformers = []
diff --git a/src/hircine/plugins/scrapers/anchira.py b/src/hircine/plugins/scrapers/anchira.py
index baee4bd..1e89ffb 100644
--- a/src/hircine/plugins/scrapers/anchira.py
+++ b/src/hircine/plugins/scrapers/anchira.py
@@ -19,7 +19,8 @@ from hircine.scraper.types import (
)
from hircine.scraper.utils import open_archive_file, parse_dict
-URL_REGEX = re.compile(r"^https?://anchira\.to/g/")
+ANCHIRA_REGEX = re.compile(r"^https?://anchira\.to/g/")
+NEXUS_REGEX = re.compile(r"^https?://hentainexus\.com/")
class AnchiraYamlScraper(Scraper):
@@ -45,8 +46,15 @@ class AnchiraYamlScraper(Scraper):
self.data = self.load()
source = self.data.get("Source")
- if source and re.match(URL_REGEX, source):
- self.is_available = True
+ if source:
+ if re.match(ANCHIRA_REGEX, source) or re.match(NEXUS_REGEX, source):
+ self.is_available = True
+ else:
+ # heuristic, but should be good enough
+ url = self.data.get("URL")
+ parody = self.data.get("Parody")
+
+ self.is_available = url is not None and parody is not None
def load(self):
try:
diff --git a/src/hircine/plugins/scrapers/schale_network.py b/src/hircine/plugins/scrapers/schale_network.py
new file mode 100644
index 0000000..e38cfe8
--- /dev/null
+++ b/src/hircine/plugins/scrapers/schale_network.py
@@ -0,0 +1,82 @@
+import re
+
+import yaml
+
+import hircine.enums as enums
+from hircine.scraper import Scraper
+from hircine.scraper.types import (
+ Artist,
+ Censorship,
+ Circle,
+ Direction,
+ Language,
+ Tag,
+ Title,
+)
+from hircine.scraper.utils import open_archive_file, parse_dict
+
+SOURCE_REGEX = re.compile(r"^SchaleNetwork:")
+
+
+class SchaleNetworkScraper(Scraper):
+ """
+ A scraper for ``info.yaml`` files found in archives downloaded from
+ *schale.network*.
+
+ .. list-table::
+ :align: left
+
+ * - **Requires**
+ - ``info.yaml`` in the archive or as a sidecar.
+ * - **Source**
+ - ``schale.network``
+ """
+
+ name = "schale.network info.yaml"
+ source = "schale.network"
+
+ def __init__(self, comic):
+ super().__init__(comic)
+
+ self.data = self.load()
+ source = self.data.get("source")
+
+ if source and re.match(SOURCE_REGEX, source):
+ self.is_available = True
+
+ def load(self):
+ try:
+ with open_archive_file(self.comic.archive, "info.yaml") as yif:
+ return yaml.safe_load(yif)
+ except Exception:
+ return {}
+
+ def scrape(self):
+ parsers = {
+ "title": Title,
+ "artist": Artist,
+ "circle": Circle,
+ "general": Tag.from_string,
+ "male": lambda s: Tag(namespace="male", tag=s),
+ "female": lambda s: Tag(namespace="female", tag=s),
+ "mixed": lambda s: Tag(namespace="mixed", tag=s),
+ "language": self.parse_language,
+ "other": self.parse_other,
+ }
+
+ yield from parse_dict(parsers, self.data)
+
+ yield Direction(enums.Direction.RIGHT_TO_LEFT)
+
+ def parse_language(self, input):
+ if not input or input in ["translated"]:
+ return
+
+ return Language.from_name(input)
+
+ def parse_other(self, input):
+ match input:
+ case "uncensored":
+ return Censorship(value=enums.Censorship.NONE)
+ case _:
+ return Tag.from_string(input)
diff --git a/src/hircine/scanner.py b/src/hircine/scanner.py
index d2b5cd3..6e3fafb 100644
--- a/src/hircine/scanner.py
+++ b/src/hircine/scanner.py
@@ -7,8 +7,8 @@ from concurrent.futures import ProcessPoolExecutor
from datetime import datetime, timezone
from enum import Enum
from hashlib import file_digest
-from typing import List, NamedTuple
-from zipfile import ZipFile, is_zipfile
+from typing import NamedTuple
+from zipfile import BadZipFile, ZipFile, is_zipfile
from blake3 import blake3
from natsort import natsorted, ns
@@ -86,7 +86,7 @@ class AddArchive(NamedTuple):
path: str
size: int
mtime: datetime
- members: List[Member]
+ members: list[Member]
async def upsert_images(self, session):
input = [
@@ -286,6 +286,11 @@ class Scanner:
hash = blake3()
with ZipFile(path, mode="r") as z:
+ try:
+ z.testzip()
+ except Exception as e:
+ raise BadZipFile(f"Corrupt zip file {path}") from e
+
input = [(path, info.filename) for info in z.infolist()]
loop = asyncio.get_event_loop()