diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/hircine/__init__.py | 2 | ||||
-rw-r--r-- | src/hircine/api/filters.py | 69 | ||||
-rw-r--r-- | src/hircine/api/inputs.py | 10 | ||||
-rw-r--r-- | src/hircine/api/mutation/resolvers.py | 9 | ||||
-rw-r--r-- | src/hircine/api/query/__init__.py | 6 | ||||
-rw-r--r-- | src/hircine/api/query/resolvers.py | 29 | ||||
-rw-r--r-- | src/hircine/api/responses.py | 30 | ||||
-rw-r--r-- | src/hircine/api/sort.py | 11 | ||||
-rw-r--r-- | src/hircine/api/types.py | 34 | ||||
-rw-r--r-- | src/hircine/db/models.py | 41 | ||||
-rw-r--r-- | src/hircine/db/ops.py | 8 | ||||
-rw-r--r-- | src/hircine/enums.py | 8 | ||||
-rw-r--r-- | src/hircine/plugins/__init__.py | 3 | ||||
-rw-r--r-- | src/hircine/plugins/scrapers/anchira.py | 14 | ||||
-rw-r--r-- | src/hircine/plugins/scrapers/schale_network.py | 82 | ||||
-rw-r--r-- | src/hircine/scanner.py | 11 |
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() |