diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 5ba3896..6453397 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -48,6 +48,10 @@ jobs: run: | poetry run alembic upgrade head + - name: Run Ruff formatter check + run: | + poetry run ruff format . --check + - name: Run Ruff linter check run: | poetry run ruff check . diff --git a/.gitignore b/.gitignore index 972dcef..1591e36 100644 --- a/.gitignore +++ b/.gitignore @@ -209,4 +209,6 @@ __marimo__/ #IDE .idea -postgres_container.test.yml \ No newline at end of file +postgres_container.test.yml + +Makefile \ No newline at end of file diff --git a/audiostats/app.py b/audiostats/app.py index 8c0d5b5..94bc614 100644 --- a/audiostats/app.py +++ b/audiostats/app.py @@ -1,11 +1,12 @@ import tomlcfg import handlers + class App(tomlcfg.BaseModel): def __init__(self): super().__init__('./config/config.toml') self._PlayListHandler = handlers.PlayListHandler(self._config['PlayList']) - def update_playlist(self, playlist : list[str]): + def update_playlist(self, playlist: list[str]): """called when updating a playlist to enter data about new objects in the music library into db""" - #do smth business logic + # do smth business logic diff --git a/audiostats/application/dto_mappers.py b/audiostats/application/dto_mappers.py index 4a04504..b3b93d4 100644 --- a/audiostats/application/dto_mappers.py +++ b/audiostats/application/dto_mappers.py @@ -2,49 +2,72 @@ from audiostats.handlers.models import AlbumDTO, TrackDTO, StatusDTO -def update_track_orm_f_dto(old : Track, new : TrackDTO) -> None: + +def update_track_orm_f_dto(old: Track, new: TrackDTO) -> None: old.title = new.title old.number = new.number old.path = new.path old.offset = new.offset old.duration = new.duration -def create_track_orm_f_dto(track : TrackDTO) -> Track: + +def create_track_orm_f_dto(track: TrackDTO) -> Track: created = Track() update_track_orm_f_dto(created, track) return created -def update_album_orm_meta_f_dto(old : Album, new : AlbumDTO): + +def update_album_orm_meta_f_dto(old: Album, new: AlbumDTO): old.title = new.title old.performer = new.performer old.year = new.year old.path = new.path old.cover = new.cover -def create_album_dto_f_orm(album : Album): - return AlbumDTO(title=album.title, - performer=album.performer, - year=album.year, - path=album.path, - cover=album.cover, - tracks=[TrackDTO(title=track.title, - number=track.number, - path=track.path, - offset=track.offset, - duration=track.duration) for track in album.tracks], - statuses=[StatusDTO(status=status.status, - success=status.success) for status in album.album_statuses]) - -def diff_album_meta(old : Album, new : AlbumDTO) -> bool: - return any([old.title != new.title, + +def create_album_dto_f_orm(album: Album): + return AlbumDTO( + title=album.title, + performer=album.performer, + year=album.year, + path=album.path, + cover=album.cover, + tracks=[ + TrackDTO( + title=track.title, + number=track.number, + path=track.path, + offset=track.offset, + duration=track.duration, + ) + for track in album.tracks + ], + statuses=[ + StatusDTO(status=status.status, success=status.success) + for status in album.album_statuses + ], + ) + + +def diff_album_meta(old: Album, new: AlbumDTO) -> bool: + return any( + [ + old.title != new.title, old.performer != new.performer, old.year != new.year, old.path != new.path, - old.cover != new.cover]) - -def diff_track(old : Track, new : TrackDTO) -> bool: - return any([old.title != new.title, - old.number != new.number, - old.path != new.path, - old.offset != new.offset, - old.duration != new.duration]) \ No newline at end of file + old.cover != new.cover, + ] + ) + + +def diff_track(old: Track, new: TrackDTO) -> bool: + return any( + [ + old.title != new.title, + old.number != new.number, + old.path != new.path, + old.offset != new.offset, + old.duration != new.duration, + ] + ) diff --git a/audiostats/db/api.py b/audiostats/db/api.py index 54f3442..eb060f7 100644 --- a/audiostats/db/api.py +++ b/audiostats/db/api.py @@ -10,8 +10,9 @@ logger = logging.getLogger(__name__) + class DBApi: - def __init__(self, db_url : str, workers : int = 5, queue_sz : int = 10): + def __init__(self, db_url: str, workers: int = 5, queue_sz: int = 10): self._session_factory = SessionFactory(db_url) self._queue: asyncio.Queue[AlbumDTO | None] = asyncio.Queue(maxsize=queue_sz) self._num_workers = workers @@ -29,8 +30,11 @@ async def _album_upserter(self): finally: self._queue.task_done() - async def upsert_albums(self, albums : Iterator[AlbumDTO]): - workers = [asyncio.create_task(self._album_upserter()) for _ in range(self._num_workers)] + async def upsert_albums(self, albums: Iterator[AlbumDTO]): + workers = [ + asyncio.create_task(self._album_upserter()) + for _ in range(self._num_workers) + ] for album in albums: await self._queue.put(album) @@ -53,4 +57,3 @@ async def get_all_albums_w_status(self): unit_of_work = UnitOfWork(sf) async with unit_of_work() as uow: return await uow.albums.all_w_status() - diff --git a/audiostats/db/models.py b/audiostats/db/models.py index 3136992..9334ed4 100644 --- a/audiostats/db/models.py +++ b/audiostats/db/models.py @@ -1,6 +1,15 @@ from datetime import datetime -from sqlalchemy import String, Integer, UniqueConstraint, ForeignKey, Float, Enum, DateTime, func +from sqlalchemy import ( + String, + Integer, + UniqueConstraint, + ForeignKey, + Float, + Enum, + DateTime, + func, +) from sqlalchemy.orm import Mapped, mapped_column, relationship, DeclarativeBase from audiostats.domain.enums import Status, Success @@ -8,8 +17,9 @@ MAX_PATH_FIELD_LEN = 200 MAX_STR_FIELD_LEN = 50 -class Base(DeclarativeBase): - ... + +class Base(DeclarativeBase): ... + class Album(Base): """Represents **albums** table line as orm object @@ -31,21 +41,30 @@ class Album(Base): UniqueConstraint('performer', 'title', name='uq_album_performer_title'), ) - id : Mapped[int] = mapped_column(Integer, primary_key=True) - title : Mapped[str] = mapped_column(String(MAX_STR_FIELD_LEN), nullable=False) - performer : Mapped[str | None] = mapped_column(String(MAX_STR_FIELD_LEN), nullable=True) - year : Mapped[int | None] = mapped_column(Integer, nullable=True) - path : Mapped[str | None] = mapped_column(String(MAX_PATH_FIELD_LEN), nullable=True) - cover : Mapped[str | None] = mapped_column(String(MAX_PATH_FIELD_LEN), nullable=True) - tracks : Mapped[list['Track']] = relationship('Track', back_populates='album', lazy='noload') - album_statuses : Mapped[list['AlbumStatus']] = relationship('AlbumStatus', back_populates='album', lazy='noload') + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(MAX_STR_FIELD_LEN), nullable=False) + performer: Mapped[str | None] = mapped_column( + String(MAX_STR_FIELD_LEN), nullable=True + ) + year: Mapped[int | None] = mapped_column(Integer, nullable=True) + path: Mapped[str | None] = mapped_column(String(MAX_PATH_FIELD_LEN), nullable=True) + cover: Mapped[str | None] = mapped_column(String(MAX_PATH_FIELD_LEN), nullable=True) + tracks: Mapped[list['Track']] = relationship( + 'Track', back_populates='album', lazy='noload' + ) + album_statuses: Mapped[list['AlbumStatus']] = relationship( + 'AlbumStatus', back_populates='album', lazy='noload' + ) def __repr__(self): - return f'' + return ( + f'' + ) def __str__(self): return f'{self.year} - {self.performer} - {self.title}' + class Track(Base): """Represents **tracks** table line as orm object @@ -58,16 +77,21 @@ class Track(Base): :ivar duration: Track duration `(in seconds)` :ivar album: Relationship to the parent album """ + __tablename__ = 'tracks' - id : Mapped[int] = mapped_column(Integer, primary_key=True) - title : Mapped[str] = mapped_column(String(MAX_STR_FIELD_LEN), nullable=False) - album_id : Mapped[int] = mapped_column(Integer, ForeignKey('albums.id', ondelete='CASCADE'), index=True) - number : Mapped[int | None] = mapped_column(Integer, nullable=True) - path : Mapped[str] = mapped_column(String(MAX_PATH_FIELD_LEN), nullable=True) - offset : Mapped[float | None] = mapped_column(Float, nullable=True) - duration : Mapped[float | None] = mapped_column(Float, nullable=True) - album : Mapped["Album"]= relationship('Album', back_populates='tracks', lazy='noload') + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(MAX_STR_FIELD_LEN), nullable=False) + album_id: Mapped[int] = mapped_column( + Integer, ForeignKey('albums.id', ondelete='CASCADE'), index=True + ) + number: Mapped[int | None] = mapped_column(Integer, nullable=True) + path: Mapped[str] = mapped_column(String(MAX_PATH_FIELD_LEN), nullable=True) + offset: Mapped[float | None] = mapped_column(Float, nullable=True) + duration: Mapped[float | None] = mapped_column(Float, nullable=True) + album: Mapped['Album'] = relationship( + 'Album', back_populates='tracks', lazy='noload' + ) def __repr__(self): return f'' @@ -89,13 +113,15 @@ class AlbumStatus(Base): __tablename__ = 'album_statuses' - id : Mapped[int] = mapped_column(Integer, primary_key=True) - album_id : Mapped[int] = mapped_column(Integer, ForeignKey('albums.id', ondelete='CASCADE'), index=True) - time_stamp : Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), default=func.now()) - status : Mapped[Status] = mapped_column(Enum(Status), nullable=False) - success : Mapped[Success] = mapped_column(Enum(Success), nullable=False) - album : Mapped["Album"] = relationship('Album', back_populates='album_statuses', lazy='noload') - - - - + id: Mapped[int] = mapped_column(Integer, primary_key=True) + album_id: Mapped[int] = mapped_column( + Integer, ForeignKey('albums.id', ondelete='CASCADE'), index=True + ) + time_stamp: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now(), default=func.now() + ) + status: Mapped[Status] = mapped_column(Enum(Status), nullable=False) + success: Mapped[Success] = mapped_column(Enum(Success), nullable=False) + album: Mapped['Album'] = relationship( + 'Album', back_populates='album_statuses', lazy='noload' + ) diff --git a/audiostats/db/repositories.py b/audiostats/db/repositories.py index 6567a44..dd8e4ce 100644 --- a/audiostats/db/repositories.py +++ b/audiostats/db/repositories.py @@ -8,24 +8,34 @@ from .models import Album, AlbumStatus from audiostats.handlers.models import AlbumDTO -from audiostats.application.dto_mappers import create_album_dto_f_orm, update_album_orm_meta_f_dto, update_track_orm_f_dto, create_track_orm_f_dto, diff_track, diff_album_meta +from audiostats.application.dto_mappers import ( + create_album_dto_f_orm, + update_album_orm_meta_f_dto, + update_track_orm_f_dto, + create_track_orm_f_dto, + diff_track, + diff_album_meta, +) logger = logging.getLogger(__name__) + class AlbumRepository: - def __init__(self, session : AsyncSession): + def __init__(self, session: AsyncSession): self._session = session - async def upsert(self, album_data : AlbumDTO): - album = await self.find_by_title_performer(album_data.title, album_data.performer) + async def upsert(self, album_data: AlbumDTO): + album = await self.find_by_title_performer( + album_data.title, album_data.performer + ) album_status = None - if not album: #if new album + if not album: # if new album album = Album() album_status = Status.ADDED self._session.add(album) - elif diff_album_meta(album, album_data): #if album meta modified + elif diff_album_meta(album, album_data): # if album meta modified album_status = Status.MODIFIED update_album_orm_meta_f_dto(album, album_data) @@ -49,27 +59,33 @@ async def upsert(self, album_data : AlbumDTO): await self._session.delete(track) if album_status: - status = AlbumStatus(album=album, status=album_status, success=Success.SUCCESS) + status = AlbumStatus( + album=album, status=album_status, success=Success.SUCCESS + ) self._session.add(status) logger.info(f'Album upserted: {album_data}') - async def find_by_title_performer(self, title : str, performer : str | None) -> Album | None: + async def find_by_title_performer( + self, title: str, performer: str | None + ) -> Album | None: result = await self._session.execute( - select(Album).where( - Album.title == title, Album.performer == performer).options( - selectinload(Album.tracks) - )) + select(Album) + .where(Album.title == title, Album.performer == performer) + .options(selectinload(Album.tracks)) + ) return result.scalar_one_or_none() async def all(self) -> list[Album]: - result = await self._session.scalars(select(Album).options( - selectinload(Album.tracks) - )) + result = await self._session.scalars( + select(Album).options(selectinload(Album.tracks)) + ) return [create_album_dto_f_orm(album) for album in result.all()] async def all_w_status(self) -> list[Album]: - result = await self._session.scalars(select(Album).options( - selectinload(Album.tracks) - ).options(selectinload(Album.album_statuses))) + result = await self._session.scalars( + select(Album) + .options(selectinload(Album.tracks)) + .options(selectinload(Album.album_statuses)) + ) return [create_album_dto_f_orm(album) for album in result.all()] diff --git a/audiostats/db/session.py b/audiostats/db/session.py index 4775815..990598a 100644 --- a/audiostats/db/session.py +++ b/audiostats/db/session.py @@ -6,8 +6,10 @@ class SessionFactory: - def __init__(self, db_url : str, max_sessions : int=20): - self._engine = create_async_engine(url=db_url, pool_size=max_sessions, max_overflow=0) + def __init__(self, db_url: str, max_sessions: int = 20): + self._engine = create_async_engine( + url=db_url, pool_size=max_sessions, max_overflow=0 + ) self._session_maker = async_sessionmaker(bind=self._engine) self._semaphore = Semaphore(max_sessions) @@ -30,15 +32,3 @@ async def get_session(self) -> AsyncIterator[AsyncSession]: if session: await session.close() self._semaphore.release() - - # async def __call__(self, *args, **kwargs) -> AsyncSession: - # await self._semaphore.acquire() - # session = self._session_maker() - # - # async def close_session(): - # await session.close() - # self._semaphore.release() - # - # session.close = close_session() - # - # return session \ No newline at end of file diff --git a/audiostats/db/uow.py b/audiostats/db/uow.py index 580a53d..5017d60 100644 --- a/audiostats/db/uow.py +++ b/audiostats/db/uow.py @@ -8,12 +8,13 @@ logger = logging.getLogger(__name__) + class UnitOfWork: def __init__(self, session_factory: SessionFactory): self._session_factory = session_factory - self._session : AsyncSession | None = None + self._session: AsyncSession | None = None - self.albums : AlbumRepository | None = None + self.albums: AlbumRepository | None = None logger.debug(f'UoF initialized: {self}') @asynccontextmanager @@ -27,4 +28,3 @@ async def __call__(self): except Exception: await self._session.rollback() raise - diff --git a/audiostats/db/worker.py b/audiostats/db/worker.py deleted file mode 100644 index e69de29..0000000 diff --git a/audiostats/domain/enums.py b/audiostats/domain/enums.py index 7453fb7..5aee754 100644 --- a/audiostats/domain/enums.py +++ b/audiostats/domain/enums.py @@ -1,9 +1,11 @@ from enum import StrEnum + class Status(StrEnum): ADDED = 'added' MODIFIED = 'modified' + class Success(StrEnum): SUCCESS = 'success' WARNING = 'warning' diff --git a/audiostats/handlers/models.py b/audiostats/handlers/models.py index 6adc408..d418ef1 100644 --- a/audiostats/handlers/models.py +++ b/audiostats/handlers/models.py @@ -3,52 +3,53 @@ from audiostats.domain.enums import Status, Success + @dataclass(slots=True, frozen=True) class StatusDTO: - status : Status - success : Success + status: Status + success: Success def __repr__(self): return f'' + @dataclass(slots=True, frozen=True) class TrackDTO: - title : str - number : int | None - path : str - offset : float | None - duration : float | None + title: str + number: int | None + path: str + offset: float | None + duration: float | None def __repr__(self): - return f'''''' + return f"""""" + @dataclass(slots=True) class AlbumDTO: - title : str - performer : str | None - year : int | None - path : str | None - cover : str | None - tracks : list[TrackDTO] - statuses : list[StatusDTO] = field(default_factory=list) + title: str + performer: str | None + year: int | None + path: str | None + cover: str | None + tracks: list[TrackDTO] + statuses: list[StatusDTO] = field(default_factory=list) def __repr__(self): - return f'''\n''' +)>""" def __eq__(self, other): if not isinstance(other, AlbumDTO): return False return ( - self.title == other.title - and self.performer == other.performer - and self.year == other.year - and self.path == other.path - and self.cover == other.cover - and Counter(self.tracks) == Counter(other.tracks) - and Counter(self.statuses) == Counter(other.statuses) + self.title == other.title + and self.performer == other.performer + and self.year == other.year + and self.path == other.path + and self.cover == other.cover + and Counter(self.tracks) == Counter(other.tracks) + and Counter(self.statuses) == Counter(other.statuses) ) - - diff --git a/audiostats/handlers/plst_handler.py b/audiostats/handlers/plst_handler.py index c5b216c..b0c6fa5 100644 --- a/audiostats/handlers/plst_handler.py +++ b/audiostats/handlers/plst_handler.py @@ -15,28 +15,32 @@ LIBROSA_AVAILABLE = False try: - #import librosa + # import librosa import librosa as _librosa + librosa = _librosa LIBROSA_AVAILABLE = True except ImportError: pass -MIN_TRACK_DURATION = 10 #Used to decide whether there are more tracks in the file or whether a new file should be started +MIN_TRACK_DURATION = 10 # Used to decide whether there are more tracks in the file or whether a new file should be started logger = logging.getLogger(__name__) -def frame_t_sec(str_time : str) -> float: + +def frame_t_sec(str_time: str) -> float: mm, ss, ff = map(float, str_time.split(':')) return mm * 60 + ss + ff / 75 # 1 frame = 1/75 sec + class PlayListHandler: """Class that processes the playlist, prepares the necessary data for each album for loading into the database""" - def __init__(self, cfg : dict[str, Any]) -> None: + + def __init__(self, cfg: dict[str, Any]) -> None: self._config = cfg - def process_playlist_paths(self, playlist : list[str]) -> Iterator[AlbumDTO]: + def process_playlist_paths(self, playlist: list[str]) -> Iterator[AlbumDTO]: """Processes the playlist line by line and provides the **AlbumDTO** objects for loading into db""" t_start = time.time() files_total = 0 @@ -48,7 +52,9 @@ def process_playlist_paths(self, playlist : list[str]) -> Iterator[AlbumDTO]: files_processed += 1 yield album files_total += 1 - logger.info(f'Playlist processed in {(time.time()-t_start)*1000:.3f} ms. Files total: {files_total}, successfully processed: {files_processed}') + logger.info( + f'Playlist processed in {(time.time() - t_start) * 1000:.3f} ms. Files total: {files_total}, successfully processed: {files_processed}' + ) def _process_cue(self, path: str) -> AlbumDTO | None: """AlbumDTO class constructor from cue sheet data""" @@ -58,7 +64,7 @@ def _process_cue(self, path: str) -> AlbumDTO | None: cue = cuetools.load(f) current_dir = os.path.dirname(path) - if not (title:=cue.title): + if not (title := cue.title): logger.warning(f'No title in album: path={path}') return None @@ -67,19 +73,27 @@ def _process_cue(self, path: str) -> AlbumDTO | None: try: year = int(cue.rem.date) except ValueError: - logger.warning(f'No correct date in album: {cue.performer} - {cue.title} - {path}') + logger.warning( + f'No correct date in album: {cue.performer} - {cue.title} - {path}' + ) else: - logger.warning(f'No any date in album: {cue.performer} - {cue.title} - {path}') - - album = AlbumDTO(title=title, - performer=cue.performer, - year=year, - path=path, - cover=self._get_cover_path(current_dir), - tracks=[i for i in self._process_cue_tracks(cue, current_dir)]) + logger.warning( + f'No any date in album: {cue.performer} - {cue.title} - {path}' + ) + + album = AlbumDTO( + title=title, + performer=cue.performer, + year=year, + path=path, + cover=self._get_cover_path(current_dir), + tracks=[i for i in self._process_cue_tracks(cue, current_dir)], + ) if len(album.tracks) < 1: - logger.warning(f'No tracks in album: {cue.performer} - {cue.title} - {path}') + logger.warning( + f'No tracks in album: {cue.performer} - {cue.title} - {path}' + ) return None logger.debug(f'\nSuccessfully processed: {album}') return album @@ -90,32 +104,50 @@ def _process_cue(self, path: str) -> AlbumDTO | None: logger.warning(f'Cant read file: {path}') return None - def _get_cover_path(self, current_dir: str) -> str | None: """returns album cover filepath""" for file in os.listdir(current_dir): - if any(file.lower() == i + j for i in self._config['CoverNames'] for j in self._config['CoverExtensions']): + if any( + file.lower() == i + j + for i in self._config['CoverNames'] + for j in self._config['CoverExtensions'] + ): return os.path.join(current_dir, file) return None - def _process_cue_tracks(self, cue : cuetools.AlbumData, current_dir : str) -> Iterator[TrackDTO]: + def _process_cue_tracks( + self, cue: cuetools.AlbumData, current_dir: str + ) -> Iterator[TrackDTO]: offset: float = 0 duration: float for track_cue in sorted(cue.tracks, reverse=True, key=lambda x: int(x.track)): - offset, duration = self._get_offset_duration(current_dir, track_cue, offset) if LIBROSA_AVAILABLE else (0.0,0.0) - if not (title:=track_cue.title): + offset, duration = ( + self._get_offset_duration(current_dir, track_cue, offset) + if LIBROSA_AVAILABLE + else (0.0, 0.0) + ) + if not (title := track_cue.title): logger.warning(f'No title in track: {track_cue.track}]') else: - track = TrackDTO(title=title, - number=int(track_cue.track), - path=os.path.join(current_dir, track_cue.link), - offset=offset if LIBROSA_AVAILABLE else None, - duration=duration if LIBROSA_AVAILABLE else None) + track = TrackDTO( + title=title, + number=int(track_cue.track), + path=os.path.join(current_dir, track_cue.link), + offset=offset if LIBROSA_AVAILABLE else None, + duration=duration if LIBROSA_AVAILABLE else None, + ) yield track - def _get_offset_duration(self, current_dir : str, track_cue : TrackData, next_offset : float | None) -> tuple[float, float]: + def _get_offset_duration( + self, current_dir: str, track_cue: TrackData, next_offset: float | None + ) -> tuple[float, float]: offset = frame_t_sec(track_cue.index['01']) if track_cue.index['01'] else 0 - duration = next_offset - offset if next_offset is not None and next_offset >= MIN_TRACK_DURATION else self._get_audiofile_duration(os.path.join(current_dir,track_cue.link)) - offset + duration = ( + next_offset - offset + if next_offset is not None and next_offset >= MIN_TRACK_DURATION + else self._get_audiofile_duration(os.path.join(current_dir, track_cue.link)) + - offset + ) return offset, duration def _get_audiofile_duration(self, path_to_file: str) -> float: @@ -128,9 +160,12 @@ def _get_audiofile_duration(self, path_to_file: str) -> float: def _get_ape_duration(self, path_to_file: str) -> float: result = subprocess.run( - [arg.replace('{path_to_file}', path_to_file) for arg in self._config['ApeDurationCmd']], + [ + arg.replace('{path_to_file}', path_to_file) + for arg in self._config['ApeDurationCmd'] + ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True + text=True, ) return float(result.stdout.strip()) diff --git a/main.py b/main.py index fab36c0..4427550 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,16 @@ import sys + def main() -> int: if len(sys.argv) < 2: print(f'{sys.argv[0]}: too few arguments') return 0 - if sys.argv[1] == 'update': #plst get | poetry run python main.py update + if sys.argv[1] == 'update': # plst get | poetry run python main.py update for line in sys.stdin: print(line) return 0 + if __name__ == '__main__': main() diff --git a/migrations/env.py b/migrations/env.py index 25a9e02..db46aaf 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -52,12 +52,12 @@ def run_migrations_offline() -> None: script output. """ - url = config.get_main_option("sqlalchemy.url") + url = config.get_main_option('sqlalchemy.url') context.configure( url=url, target_metadata=target_metadata, literal_binds=True, - dialect_opts={"paramstyle": "named"}, + dialect_opts={'paramstyle': 'named'}, ) with context.begin_transaction(): @@ -73,14 +73,12 @@ def run_migrations_online() -> None: """ connectable = engine_from_config( config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", + prefix='sqlalchemy.', poolclass=pool.NullPool, ) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/migrations/versions/a337b3f88bf1_added_album_status.py b/migrations/versions/a337b3f88bf1_added_album_status.py index 54026ac..bd7c4b0 100644 --- a/migrations/versions/a337b3f88bf1_added_album_status.py +++ b/migrations/versions/a337b3f88bf1_added_album_status.py @@ -5,6 +5,7 @@ Create Date: 2025-09-16 20:08:54.362324 """ + from typing import Sequence, Union from alembic import op @@ -21,16 +22,25 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('album_statuses', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('album_id', sa.Integer(), nullable=False), - sa.Column('time_stamp', sa.DateTime(), nullable=False), - sa.Column('status', sa.Enum('ADDED', 'MODIFIED', name='status'), nullable=False), - sa.Column('success', sa.Enum('SUCCESS', 'WARNING', 'ERROR', name='success'), nullable=False), - sa.ForeignKeyConstraint(['album_id'], ['albums.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') + op.create_table( + 'album_statuses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('album_id', sa.Integer(), nullable=False), + sa.Column('time_stamp', sa.DateTime(), nullable=False), + sa.Column( + 'status', sa.Enum('ADDED', 'MODIFIED', name='status'), nullable=False + ), + sa.Column( + 'success', + sa.Enum('SUCCESS', 'WARNING', 'ERROR', name='success'), + nullable=False, + ), + sa.ForeignKeyConstraint(['album_id'], ['albums.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + op.f('ix_album_statuses_album_id'), 'album_statuses', ['album_id'], unique=False ) - op.create_index(op.f('ix_album_statuses_album_id'), 'album_statuses', ['album_id'], unique=False) # ### end Alembic commands ### diff --git a/migrations/versions/bd8d699fa622_album_and_track_tables_init.py b/migrations/versions/bd8d699fa622_album_and_track_tables_init.py index 7737093..f71f108 100644 --- a/migrations/versions/bd8d699fa622_album_and_track_tables_init.py +++ b/migrations/versions/bd8d699fa622_album_and_track_tables_init.py @@ -1,10 +1,11 @@ """album and track tables init Revision ID: bd8d699fa622 -Revises: +Revises: Create Date: 2025-09-12 23:16:13.273814 """ + from typing import Sequence, Union from alembic import op @@ -21,26 +22,28 @@ def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('albums', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sa.String(length=50), nullable=False), - sa.Column('performer', sa.String(length=50), nullable=True), - sa.Column('year', sa.Integer(), nullable=True), - sa.Column('path', sa.String(length=200), nullable=True), - sa.Column('cover', sa.String(length=200), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('performer', 'title', name='uq_album_performer_title') + op.create_table( + 'albums', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=50), nullable=False), + sa.Column('performer', sa.String(length=50), nullable=True), + sa.Column('year', sa.Integer(), nullable=True), + sa.Column('path', sa.String(length=200), nullable=True), + sa.Column('cover', sa.String(length=200), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('performer', 'title', name='uq_album_performer_title'), ) - op.create_table('tracks', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('title', sa.String(length=50), nullable=False), - sa.Column('album_id', sa.Integer(), nullable=False), - sa.Column('number', sa.Integer(), nullable=True), - sa.Column('path', sa.String(length=200), nullable=True), - sa.Column('offset', sa.Float(), nullable=True), - sa.Column('duration', sa.Float(), nullable=True), - sa.ForeignKeyConstraint(['album_id'], ['albums.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') + op.create_table( + 'tracks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=50), nullable=False), + sa.Column('album_id', sa.Integer(), nullable=False), + sa.Column('number', sa.Integer(), nullable=True), + sa.Column('path', sa.String(length=200), nullable=True), + sa.Column('offset', sa.Float(), nullable=True), + sa.Column('duration', sa.Float(), nullable=True), + sa.ForeignKeyConstraint(['album_id'], ['albums.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), ) op.create_index(op.f('ix_tracks_album_id'), 'tracks', ['album_id'], unique=False) # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index fe1cba3..6949d4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,9 @@ ruff = "^0.14.1" [tool.ruff] target-version = "py312" +[tool.ruff.format] +quote-style = "single" + [tool.mypy] python_version = "3.12" ignore_missing_imports = true diff --git a/tests/__init__.py b/tests/__init__.py index 44cd5ed..d84a40e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -# from .db_fixture import test_engine, test_session_factory#, setup_database \ No newline at end of file +# from .db_fixture import test_engine, test_session_factory#, setup_database diff --git a/tests/conftest.py b/tests/conftest.py index 317a678..d97e8c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,26 +9,36 @@ from audiostats.handlers.models import AlbumDTO, TrackDTO, StatusDTO from audiostats.domain.enums import Status, Success + @pytest.fixture def plst_handler_instance(): class PlstHandlerApp(tomlcfg.BaseModel): def __init__(self): super().__init__('./config/config.toml') self.plst_handler = PlayListHandler(self._config['PlayList']) + app = PlstHandlerApp() return app.plst_handler -@pytest.fixture(autouse=True, scope="session") + +@pytest.fixture(autouse=True, scope='session') def setup_test_logging(): """Basic test logging configuration""" logging.basicConfig( - level=logging.DEBUG, - format='%(levelname)s:%(name)s:%(message)s' + level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s' ) + @pytest.fixture def playlist(): - return ['/music/Album1/Album1.cue', '/music/Track1.flac', '/music/Album2/Album2.cue', '/music/Album3/Album3.cue', '/music/Album4/Album4.cue'] + return [ + '/music/Album1/Album1.cue', + '/music/Track1.flac', + '/music/Album2/Album2.cue', + '/music/Album3/Album3.cue', + '/music/Album4/Album4.cue', + ] + @pytest.fixture() def mock_files(monkeypatch): @@ -36,22 +46,63 @@ def mock_open(filename, *args, **kwargs): class MockFile: def __enter__(self): return self + def __exit__(self, *args, **kwargs): pass + def read(self): if filename == '/music/Album1/Album1.cue': return '' if filename == '/music/Album2/Album2.cue': - tracks = [TrackData(index={'01' : f'{i//2}{(i%2)*5}:00:00'}, track=f'0{i+1}', title=f'Track 0{i+1}', link='Album.flac') for i in range(5)] - cue_sheet = cuetools.AlbumData(performer='The Performer',title='The Title Of Album2', rem=cuetools.RemData(genre='Rock', date='1969'), tracks=tracks) + tracks = [ + TrackData( + index={'01': f'{i // 2}{(i % 2) * 5}:00:00'}, + track=f'0{i + 1}', + title=f'Track 0{i + 1}', + link='Album.flac', + ) + for i in range(5) + ] + cue_sheet = cuetools.AlbumData( + performer='The Performer', + title='The Title Of Album2', + rem=cuetools.RemData(genre='Rock', date='1969'), + tracks=tracks, + ) return cuetools.dumps(cue_sheet) if filename == '/music/Album3/Album3.cue': - tracks = [TrackData(index={'01' : '00:00:00'}, track=f'0{i+1}', title=f'Track 0{i+1}', link=f'Track0{i+1}.flac') for i in range(5)] - cue_sheet = cuetools.AlbumData(performer='The Performer',title='The Title Of Album3', rem=cuetools.RemData(genre='Rock', date='1969'), tracks=tracks) + tracks = [ + TrackData( + index={'01': '00:00:00'}, + track=f'0{i + 1}', + title=f'Track 0{i + 1}', + link=f'Track0{i + 1}.flac', + ) + for i in range(5) + ] + cue_sheet = cuetools.AlbumData( + performer='The Performer', + title='The Title Of Album3', + rem=cuetools.RemData(genre='Rock', date='1969'), + tracks=tracks, + ) return cuetools.dumps(cue_sheet) if filename == '/music/Album4/Album4.cue': - tracks = [TrackData(index={'01' : f'{(i%3)//2}{((i%3)%2)*5}:00:00'}, track=f'0{i+1}', title=f'Track 0{i+1}', link=f'Side{'A' if i < 3 else 'B'}.flac') for i in range(6)] - cue_sheet = cuetools.AlbumData(performer='The Performer',title='The Title Of Album4', rem=cuetools.RemData(genre='Rock', date='1970'), tracks=tracks) + tracks = [ + TrackData( + index={'01': f'{(i % 3) // 2}{((i % 3) % 2) * 5}:00:00'}, + track=f'0{i + 1}', + title=f'Track 0{i + 1}', + link=f'Side{"A" if i < 3 else "B"}.flac', + ) + for i in range(6) + ] + cue_sheet = cuetools.AlbumData( + performer='The Performer', + title='The Title Of Album4', + rem=cuetools.RemData(genre='Rock', date='1970'), + tracks=tracks, + ) return cuetools.dumps(cue_sheet) else: raise FileNotFoundError() @@ -63,9 +114,12 @@ def readline(self): lines = self.read().splitlines() for line in lines: yield line + '\n' + return MockFile() + monkeypatch.setattr('builtins.open', mock_open) + @pytest.fixture def mock_listdir(monkeypatch): def mock_os_listdir(path): @@ -78,12 +132,14 @@ def mock_os_listdir(path): if path in responses: return responses[path] raise FileNotFoundError() + monkeypatch.setattr('os.listdir', mock_os_listdir) -@pytest.fixture(autouse=True, scope="function") + +@pytest.fixture(autouse=True, scope='function') def mock_get_duration(monkeypatch): duration_map = { - '/music/Album2/Album.flac': 25.0*60, + '/music/Album2/Album.flac': 25.0 * 60, '/music/Album3/Track01.flac': 5.0 * 60, '/music/Album3/Track02.flac': 5.0 * 60, '/music/Album3/Track03.flac': 5.0 * 60, @@ -97,36 +153,76 @@ def mock_duration(self, path_to_file: str) -> float: return duration_map.get(path_to_file) from audiostats.handlers.plst_handler import PlayListHandler + monkeypatch.setattr(PlayListHandler, '_get_audiofile_duration', mock_duration) + @pytest.fixture() def processed_album_dtos(): album_list = [] - album2 = AlbumDTO(title='The Title Of Album2', performer='The Performer', year=1969, - path='/music/Album2/Album2.cue', cover='/music/Album2/Front.jpg', - tracks=[ - TrackDTO(f'Track 0{i + 1}', number=i + 1, path='/music/Album2/Album.flac', offset=i*5.0*60 if LIBROSA_AVAILABLE else None, - duration=5.0*60 if LIBROSA_AVAILABLE else None) for i in range(4, -1, -1)]) - - album3 = AlbumDTO(title='The Title Of Album3', performer='The Performer', year=1969, - path='/music/Album3/Album3.cue', cover='/music/Album3/Front.png', - tracks=[ - TrackDTO(f'Track 0{i + 1}', number=i + 1, path=f'/music/Album3/Track0{i+1}.flac', offset=0.0 if LIBROSA_AVAILABLE else None, - duration=5.0*60 if LIBROSA_AVAILABLE else None) for i in range(4, -1, -1)]) - - album4 = AlbumDTO(title='The Title Of Album4', performer='The Performer', year=1970, - path='/music/Album4/Album4.cue', cover='/music/Album4/Cover.jpg', - tracks=[ - TrackDTO(f'Track 0{i + 1}', number=i + 1, path=f'/music/Album4/Side{'A' if i < 3 else 'B'}.flac', - offset=(i * 5.0 * 60 if i < 3 else (i - 3) * 5.0 * 60) if LIBROSA_AVAILABLE else None, - duration=5.0 * 60 if LIBROSA_AVAILABLE else None) for i in range(5, -1, -1)]) + album2 = AlbumDTO( + title='The Title Of Album2', + performer='The Performer', + year=1969, + path='/music/Album2/Album2.cue', + cover='/music/Album2/Front.jpg', + tracks=[ + TrackDTO( + f'Track 0{i + 1}', + number=i + 1, + path='/music/Album2/Album.flac', + offset=i * 5.0 * 60 if LIBROSA_AVAILABLE else None, + duration=5.0 * 60 if LIBROSA_AVAILABLE else None, + ) + for i in range(4, -1, -1) + ], + ) + + album3 = AlbumDTO( + title='The Title Of Album3', + performer='The Performer', + year=1969, + path='/music/Album3/Album3.cue', + cover='/music/Album3/Front.png', + tracks=[ + TrackDTO( + f'Track 0{i + 1}', + number=i + 1, + path=f'/music/Album3/Track0{i + 1}.flac', + offset=0.0 if LIBROSA_AVAILABLE else None, + duration=5.0 * 60 if LIBROSA_AVAILABLE else None, + ) + for i in range(4, -1, -1) + ], + ) + + album4 = AlbumDTO( + title='The Title Of Album4', + performer='The Performer', + year=1970, + path='/music/Album4/Album4.cue', + cover='/music/Album4/Cover.jpg', + tracks=[ + TrackDTO( + f'Track 0{i + 1}', + number=i + 1, + path=f'/music/Album4/Side{"A" if i < 3 else "B"}.flac', + offset=(i * 5.0 * 60 if i < 3 else (i - 3) * 5.0 * 60) + if LIBROSA_AVAILABLE + else None, + duration=5.0 * 60 if LIBROSA_AVAILABLE else None, + ) + for i in range(5, -1, -1) + ], + ) album_list.append(album2) album_list.append(album3) album_list.append(album4) return album_list + @pytest.fixture() def processed_album_dtos_w_status(processed_album_dtos): for i in processed_album_dtos: - i.statuses.append(StatusDTO(status=Status.ADDED, success=Success.SUCCESS)) \ No newline at end of file + i.statuses.append(StatusDTO(status=Status.ADDED, success=Success.SUCCESS)) diff --git a/tests/test_db_api.py b/tests/test_db_api.py index bc0457d..b0c6d00 100644 --- a/tests/test_db_api.py +++ b/tests/test_db_api.py @@ -14,13 +14,13 @@ @pytest.mark.asyncio async def test_async_upsert_albums(processed_album_dtos): - #db api initialisation + # db api initialisation dotenv.load_dotenv() db_url = os.getenv('ASYNC_DB_URL') api = DBApi(db_url) - #Upserting base album set + # Upserting base album set logger.info(f'incoming dtos: {processed_album_dtos}') await api.upsert_albums(processed_album_dtos) @@ -33,16 +33,24 @@ async def test_async_upsert_albums(processed_album_dtos): for i in target_album_dtos: i.statuses.append(StatusDTO(status=Status.ADDED, success=Success.SUCCESS)) - assert sorted(albums, key=lambda x: x.title) == sorted(target_album_dtos, - key=lambda x: x.title), 'Insert some albums to db' + assert sorted(albums, key=lambda x: x.title) == sorted( + target_album_dtos, key=lambda x: x.title + ), 'Insert some albums to db' - #Upserting muted album set + # Upserting muted album set muted_album_dtos = copy.deepcopy(processed_album_dtos) muted_album_dtos[0].tracks.pop(-1) muted_album_dtos[0].tracks.pop(2) muted_album_dtos[1].tracks.append( - TrackDTO(title='New_Track_1', number=6, path='music/new_track_1.flac', offset=None, duration=None)) + TrackDTO( + title='New_Track_1', + number=6, + path='music/new_track_1.flac', + offset=None, + duration=None, + ) + ) muted_album_dtos[1].year = None logger.info(f'muted album dtos: {muted_album_dtos}') @@ -56,9 +64,13 @@ async def test_async_upsert_albums(processed_album_dtos): target_muted_album_dtos = copy.deepcopy(muted_album_dtos) for i in target_muted_album_dtos: i.statuses.append(StatusDTO(status=Status.ADDED, success=Success.SUCCESS)) - target_muted_album_dtos[0].statuses.append(StatusDTO(status=Status.MODIFIED, success=Success.SUCCESS)) - target_muted_album_dtos[1].statuses.append(StatusDTO(status=Status.MODIFIED, success=Success.SUCCESS)) - - assert sorted(albums, key=lambda x: x.title) == sorted(target_muted_album_dtos, - key=lambda x: x.title), 'Update some albums in db' - + target_muted_album_dtos[0].statuses.append( + StatusDTO(status=Status.MODIFIED, success=Success.SUCCESS) + ) + target_muted_album_dtos[1].statuses.append( + StatusDTO(status=Status.MODIFIED, success=Success.SUCCESS) + ) + + assert sorted(albums, key=lambda x: x.title) == sorted( + target_muted_album_dtos, key=lambda x: x.title + ), 'Update some albums in db' diff --git a/tests/test_plst_handler.py b/tests/test_plst_handler.py index feab148..fdb6797 100644 --- a/tests/test_plst_handler.py +++ b/tests/test_plst_handler.py @@ -1,9 +1,15 @@ - -def test_process_playlist_paths(plst_handler_instance, playlist, mock_files, mock_listdir, mock_get_duration, processed_album_dtos): +def test_process_playlist_paths( + plst_handler_instance, + playlist, + mock_files, + mock_listdir, + mock_get_duration, + processed_album_dtos, +): app = plst_handler_instance res = [i for i in app.process_playlist_paths(playlist)] target = processed_album_dtos assert target[0] == res[0], 'one cue one flac' assert target[1] == res[1], 'one track one flac' - assert target[2] == res[2], 'one side one flac' \ No newline at end of file + assert target[2] == res[2], 'one side one flac'