diff --git a/pyproject.toml b/pyproject.toml index f14969f..12f997b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,18 +7,20 @@ maintainers = [{name="Commandcracker"}] license = {file = "LICENSE.txt"} readme = "README.md" dependencies = [ - "textual>=3.1.1", + "textual>=3.2.0", "textual-image[textual]>=0.8.2", "beautifulsoup4>=4.13.4", "httpx[http2]>=0.28.1", "pypresence>=4.3.0", "packaging>=25.0", - "platformdirs>=4.3.7", + "platformdirs>=4.3.8", "toml>=0.10.2", "fuzzywuzzy>=0.18.0", "async_lru>=2.0.5", - "rich-argparse>=1.7.0" - #"yt-dlp>=2025.3.31", + "rich-argparse>=1.7.0", + "SQLAlchemy>=2.0.40", + "alembic>=1.15.2" + #"yt-dlp>=2025.4.30", #"mpv>=1.0.8", ] keywords = [ @@ -52,7 +54,7 @@ classifiers = [ [project.optional-dependencies] speedups = [ "levenshtein>=0.27.1", - "orjson>=3.10.16" + "orjson>=3.10.18" ] socks = ["httpx[socks]>=0.28.1"] diff --git a/src/gucken/__init__.py b/src/gucken/__init__.py index 04dd7fd..b035eef 100644 --- a/src/gucken/__init__.py +++ b/src/gucken/__init__.py @@ -1,4 +1,6 @@ -import warnings -warnings.filterwarnings('ignore', message='Using slow pure-python SequenceMatcher. Install python-Levenshtein to remove this warning') - -__version__ = "0.3.7" +from warnings import filterwarnings as _filterwarnings +_filterwarnings( + 'ignore', + 'Using slow pure-python SequenceMatcher. Install python-Levenshtein to remove this warning' +) +__version__ = "0.3.8" diff --git a/src/gucken/_logging.py b/src/gucken/_logging.py new file mode 100644 index 0000000..0c6a0ae --- /dev/null +++ b/src/gucken/_logging.py @@ -0,0 +1,35 @@ +import logging +from platformdirs import user_log_path + +logs_path = user_log_path("gucken", ensure_exists=True) + + +def setup_sqlalchemy(): + logger = logging.getLogger("sqlalchemy") + logger.setLevel(logging.DEBUG) + file_handler = logging.FileHandler( + filename=logs_path.joinpath("sqlalchemy.log"), + encoding="utf-8" + ) + file_handler.setFormatter(logging.Formatter( + fmt="[%(asctime)s %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + )) + logger.addHandler(file_handler) + logger.propagate = False + + +def setup_global(): + logging.basicConfig( + filename=logs_path.joinpath("gucken.log"), + encoding="utf-8", + level=logging.DEBUG, + force=True, + format="[%(asctime)s %(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + +def setup(): + setup_global() + setup_sqlalchemy() \ No newline at end of file diff --git a/src/gucken/alembic.ini b/src/gucken/alembic.ini new file mode 100644 index 0000000..2f31afc --- /dev/null +++ b/src/gucken/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = [%(asctime)s %(name)s %(levelname)s] %(message)s +datefmt = %Y-%m-%d %H:%M:%S \ No newline at end of file diff --git a/src/gucken/alembic/env.py b/src/gucken/alembic/env.py new file mode 100644 index 0000000..63757f2 --- /dev/null +++ b/src/gucken/alembic/env.py @@ -0,0 +1,91 @@ +import importlib +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +# add your model's MetaData object here +# for 'autogenerate' support + +try: + from gucken.schema import metadata as target_metadata +except ImportError: + target_metadata = importlib.import_module("schema", package="gucken").metadata + + +from pathlib import Path +from platformdirs import user_data_path + +config.set_main_option("script_location", str(Path(__file__).parent)) +db_path = user_data_path("gucken").joinpath("data.db") +config.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/src/gucken/alembic/script.py.mako b/src/gucken/alembic/script.py.mako new file mode 100644 index 0000000..bc6d2b7 --- /dev/null +++ b/src/gucken/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/src/gucken/alembic/versions/b9728daf0b07_init.py b/src/gucken/alembic/versions/b9728daf0b07_init.py new file mode 100644 index 0000000..af7d874 --- /dev/null +++ b/src/gucken/alembic/versions/b9728daf0b07_init.py @@ -0,0 +1,44 @@ +"""init + +Revision ID: b9728daf0b07 +Revises: +Create Date: 2024-06-21 01:15:44.549492 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b9728daf0b07' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('anime', + sa.Column('anime_id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('provider', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('anime_id', name=op.f('pk__anime')) + ) + op.create_table('watchtime', + sa.Column('anime_id', sa.Integer(), nullable=False), + sa.Column('episode', sa.Integer(), nullable=False), + sa.Column('season', sa.Integer(), nullable=False), + sa.Column('time', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['anime_id'], ['anime.anime_id'], name=op.f('fk__watchtime__anime_id__anime')), + sa.PrimaryKeyConstraint('anime_id', 'episode', 'season', name=op.f('pk__watchtime')) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('watchtime') + op.drop_table('anime') + # ### end Alembic commands ### \ No newline at end of file diff --git a/src/gucken/db.py b/src/gucken/db.py new file mode 100644 index 0000000..dbbf5e9 --- /dev/null +++ b/src/gucken/db.py @@ -0,0 +1,38 @@ +from pathlib import Path +import sys + +from alembic.config import Config +from alembic import command +from sqlalchemy.orm import sessionmaker +from sqlalchemy import create_engine +from platformdirs import user_data_path + +from gucken._logging import logs_path + + +class Singleton: + _instance = None + + def __new__(cls, *args, **kwargs): + if not cls._instance: + cls._instance = super().__new__(cls) + return cls._instance + + +class SingletonEngine(Singleton): + def __init__(self): + alembic_cfg = Config(Path(__file__).parent.joinpath("alembic.ini")) + alembic_cfg.set_main_option("script_location", str(Path(__file__).parent.joinpath("alembic"))) + + db_path = user_data_path("gucken").joinpath("data.db") + self.engine = create_engine(f"sqlite:///{db_path}", echo=True) + + _stderr = sys.stderr + sys.stderr = logs_path.joinpath("alembic.log").open("a") + command.upgrade(alembic_cfg, "head") + sys.stderr.close() + sys.stderr = _stderr + + +engine = SingletonEngine().engine +Session = sessionmaker(bind=engine) \ No newline at end of file diff --git a/src/gucken/gucken.py b/src/gucken/gucken.py index 2d33f3f..14828fd 100644 --- a/src/gucken/gucken.py +++ b/src/gucken/gucken.py @@ -1,21 +1,23 @@ -import argparse +from ._logging import setup import logging +from platformdirs import user_config_path, user_log_path, user_data_path +from rich.style import Style +import argparse from asyncio import gather, set_event_loop, new_event_loop from atexit import register as register_atexit -from os import remove, name as os_name from os.path import join from pathlib import Path from random import choice from shutil import which from subprocess import DEVNULL, PIPE, Popen from time import sleep, time -from typing import ClassVar, List, Union +from typing import ClassVar, List, Union, Self from async_lru import alru_cache -from os import getenv +from os import getenv, chdir, remove, name as os_name +from sys import argv from io import BytesIO from fuzzywuzzy import fuzz -from platformdirs import user_config_path, user_log_path from pypresence import AioPresence, DiscordNotFound from rich.markup import escape from textual import events, on, work @@ -62,6 +64,32 @@ from .utils import detect_player, is_android, set_default_vlc_interface_cfg, get_vlc_intf_user_path from .networking import AsyncClient from . import __version__ +from .db import Session +from .schema import Anime, Watchtime +from sqlalchemy.orm.session import Session as ORMSession + +from sqlite3.__main__ import main as sqlite3_main +from alembic.__main__ import main as alembic_main + + +def seconds_to_hms(seconds: int) -> str: + hours, remainder = divmod(seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return f"{hours:02}:{minutes:02}:{seconds:02}" + + +def hms_to_seconds(hms: str) -> int: + hours, minutes, seconds = map(int, hms.split(":")) + return hours * 3600 + minutes * 60 + seconds + + +def get_anime(session: Session, provider: str, name: str) -> Anime: + anime = session.query(Anime).filter_by(provider=provider, name=name).first() + if not anime: + anime = Anime(provider=provider, name=name) + session.add(anime) + session.commit() + return anime def sort_favorite_lang( @@ -207,6 +235,37 @@ def on_click(self, event: events.Click) -> None: self.last_click[row_index] = time() +class SeasonListDataTable(ClickableDataTable): + COMPONENT_CLASSES = { + "seasonlistdatatable--completed", + "seasonlistdatatable--started" + } + DEFAULT_CSS = """ + SeasonListDataTable > .seasonlistdatatable--completed { + background: rgb(0,154,23); + } + SeasonListDataTable > .seasonlistdatatable--started { + background: rgb(249,101,21); + } + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.completed: set[int] = set() + self.started: set[int] = set() + + def _get_row_style(self, row_index: int, base_style: Style) -> Style: + if row_index in self.completed: + return self.get_component_styles("seasonlistdatatable--completed").rich_style + if row_index in self.started: + return self.get_component_styles("seasonlistdatatable--started").rich_style + return super()._get_row_style(row_index, base_style) + + def clear(self, columns: bool = False) -> Self: + self.completed = [] + return super().clear(columns) + + def remove_duplicates(lst: list) -> list: """ Why this instead of a set you ask ? @@ -252,10 +311,11 @@ class GuckenApp(App): # TODO: theme_changed_signal - def __init__(self, debug: bool, search: str): + def __init__(self, debug: bool, search: str, session: ORMSession): super().__init__(watch_css=debug) self._debug = debug self._search = search + self.session: ORMSession = session self.current: Union[list[SearchResult], None] = None self.current_info: Union[Series, None] = None @@ -318,7 +378,7 @@ def compose(self) -> ComposeResult: id="res_con_2" ) - yield ClickableDataTable(id="season_list") + yield SeasonListDataTable(id="season_list") with TabPane("Settings", id="setting"): # Settings "⚙" # TODO: dont show unneeded on android with ScrollableContainer(id="settings_container"): @@ -490,7 +550,7 @@ def set_search(): self.query_one("#info", TabPane).set_loading(True) - table = self.query_one("#season_list", DataTable) + table = self.query_one("#season_list", SeasonListDataTable) table.cursor_type = "row" if self.query_one("#update_checker", RadioButton).value is True: @@ -630,12 +690,12 @@ async def on_key(self, event: events.Key) -> None: inp.action_delete_left() else: await inp.on_event(event) - if key == "enter" and self.query_one("#season_list", DataTable).has_focus: + if key == "enter" and self.query_one("#season_list", SeasonListDataTable).has_focus: self.play_selected() @work(exclusive=True) async def play_selected(self): - dt = self.query_one("#season_list", DataTable) + dt = self.query_one("#season_list", SeasonListDataTable) # TODO: show loading #dt.set_loading(True) index = self.app.query_one("#results", ListView).index @@ -676,9 +736,16 @@ async def open_info(self) -> None: # make sure to reset colum spacing table.clear(columns=True) table.add_columns("FT", "S", "F", "Title", "Hoster", "Sprache") + index = {} c = 0 for ep in series.episodes: + ep: Episode + + if not index.get(ep.season): + index[ep.season] = {} + index[ep.season][ep.episode_number] = c + hl = [] for h in ep.available_hoster: hl.append(hoster.get_key(h)) @@ -697,6 +764,19 @@ async def open_info(self) -> None: " ".join(ll), ) info_tab.set_loading(False) + anime = self.session.query(Anime).filter_by( + provider=series_search_result.provider_name, + name=series_search_result.name + ).first() + if anime: + watchtimes = self.session.query(Watchtime).filter_by(anime_id=anime.anime_id) + _started = set() + for watchtime in watchtimes: + _started.add(index[watchtime.season][watchtime.episode]) + table.started = _started + + # table.completed = {0} + # table.started = {1} @work(exclusive=True, thread=True) async def update_check(self): @@ -802,6 +882,16 @@ async def update(): chapters_file = None + if isinstance(_player, MPVPlayer): + anime = get_anime(self.session, series_search_result.provider_name, series_search_result.name) + watchtime = self.session.query(Watchtime).filter_by( + anime_id=anime.anime_id, + episode=episode.episode_number, + season=episode.season + ).first() + if watchtime: + args.append(f"--start={seconds_to_hms(int(watchtime.time))}") + # TODO: cache more # TODO: Support based on mpv # TODO: recover start --start=00:56 @@ -926,8 +1016,24 @@ def delete_chapters_file(): resume_time = sp[1] if resume_time: - logging.info("Resume: %s", resume_time) - + logging.info("Resume time: %s", resume_time) + anime = get_anime(self.session, series_search_result.provider_name, series_search_result.name) + existing_watchtime = self.session.query(Watchtime).filter_by( + anime_id=anime.anime_id, + episode=episode.episode_number, + season=episode.season + ).first() + if existing_watchtime: + existing_watchtime.time = hms_to_seconds(resume_time) + else: + watchtime = Watchtime( + anime_id=anime.anime_id, + episode=episode.episode_number, + season=episode.season, + time=hms_to_seconds(resume_time) + ) + self.session.add(watchtime) + self.session.commit() exit_code = process.poll() if exit_code is not None: @@ -973,12 +1079,24 @@ async def play_next(should_next): def main(): + if len(argv) >= 2: + if argv[1] == "alembic": + chdir(Path(__file__).parent) + alembic_main(prog="gucken alembic", argv=argv[2:]) + return + if argv[1] == "sql": + sqlite3_main([str(user_data_path("gucken").joinpath("data.db"))] + argv[2:]) + return parser = argparse.ArgumentParser( prog='gucken', description="Gucken is a Terminal User Interface which allows you to browse and watch your favorite anime's with style.", formatter_class=RichHelpFormatter ) - parser.add_argument("search", nargs='?') + subparsers = parser.add_subparsers(dest="command") + search_parser = subparsers.add_parser("search") + search_parser.add_argument("kw", nargs='?') + subparsers.add_parser("alembic") + subparsers.add_parser("sql") parser.add_argument( "--debug", "--dev", action="store_true", @@ -993,14 +1111,11 @@ def main(): if args.version: exit(f"gucken {__version__}") if args.debug: - logs_path = user_log_path("gucken", ensure_exists=True) - logging.basicConfig( - filename=logs_path.joinpath("gucken.log"), encoding="utf-8", level=logging.INFO, force=True - ) + setup() register_atexit(gucken_settings_manager.save) print(f"\033]0;Gucken {__version__}\007", end='', flush=True) - gucken_app = GuckenApp(debug=args.debug, search=args.search) + gucken_app = GuckenApp(debug=args.debug, search=args.kw if hasattr(args, "kw") else None, session=Session()) gucken_app.run() print(choice(exit_quotes)) diff --git a/src/gucken/networking.py b/src/gucken/networking.py index f493df1..793c7aa 100644 --- a/src/gucken/networking.py +++ b/src/gucken/networking.py @@ -1,12 +1,11 @@ from enum import Enum from random import choice -from urllib.parse import urlparse from typing import Union +from logging import info +from asyncio import run -from httpx import AsyncClient as HttpxAsyncClient, Response, AsyncBaseTransport - +from httpx import AsyncClient as HttpxAsyncClient, Response, AsyncBaseTransport, Request from rich import print -from asyncio import run # https://www.useragents.me/ @@ -125,16 +124,23 @@ def __init__( super().__init__(*args, **kwargs) - async def request(self, *args, **kwargs) -> Response: + async def send( + self, + request: Request, + **kwargs + ) -> Response: if self.auto_referer is True: - parsed_url = urlparse(args[1]) # maby use httpx.URL instead ? - base_url = f'{parsed_url.scheme}://{parsed_url.netloc}' - headers = {"Referer": base_url} - if kwargs.get("headers") is not None: - headers = {**kwargs.get("headers"), **headers} - kwargs["headers"] = headers - return await super().request(*args, **kwargs) - + request.headers["Referer"] = str(request.url.copy_with(path="/", query=None)) + response = await super().send(request, **kwargs) + info( + 'HTTP Request: %s %s "%s %d %s"', + request.method, + request.url, + response.http_version, + response.status_code, + response.reason_phrase, + ) + return response async def main(): from .utils import json_loads diff --git a/src/gucken/schema.py b/src/gucken/schema.py new file mode 100644 index 0000000..2d34cd4 --- /dev/null +++ b/src/gucken/schema.py @@ -0,0 +1,56 @@ +""" +Data structures, used in project. + +You may do changes in tables here, then execute +`alembic revision --message="Your text" --autogenerate` +and alembic would generate new migration for you +in staff/alembic/versions folder. +""" +from sqlalchemy import Column, Integer, String, Float, ForeignKey, MetaData +from sqlalchemy.orm import declarative_base + + +# Default naming convention for all indexes and constraints +# See why this is important and how it would save your time: +# https://alembic.sqlalchemy.org/en/latest/naming.html +convention = { + 'all_column_names': lambda constraint, table: '_'.join([ + column.name for column in constraint.columns.values() + ]), + 'ix': 'ix__%(table_name)s__%(all_column_names)s', + 'uq': 'uq__%(table_name)s__%(all_column_names)s', + 'ck': 'ck__%(table_name)s__%(constraint_name)s', + 'fk': ( + 'fk__%(table_name)s__%(all_column_names)s__' + '%(referred_table_name)s' + ), + 'pk': 'pk__%(table_name)s' +} + +# Registry for all tables +metadata = MetaData(naming_convention=convention) +Base = declarative_base(metadata=metadata) + + +class Anime(Base): + __tablename__ = 'anime' + + anime_id = Column(Integer, primary_key=True, autoincrement=True, nullable=False) + provider = Column(String, nullable=False) + name = Column(String, nullable=False) + + def __repr__(self) -> str: + return f"Anime(anime_id={self.anime_id!r}, provider={self.provider!r}, name={self.name!r})" + + +class Watchtime(Base): + __tablename__ = 'watchtime' + + #watchtime_id = Column(Integer, primary_key=True, autoincrement=True, nullable=False) + anime_id = Column(Integer, ForeignKey('anime.anime_id'), primary_key=True, nullable=False) + episode = Column(Integer, primary_key=True, nullable=False) + season = Column(Integer, primary_key=True, nullable=False) + time = Column(Float, nullable=False) + + def __repr__(self) -> str: + return f"Watchtime(watchtime_id={self.watchtime_id!r}, anime_id={self.anime_id!r}, episode={self.episode!r}, season={self.season!r}, time={self.time!r})" \ No newline at end of file