From 9ebe776471d424551761f66a661c2f3cf335fbc7 Mon Sep 17 00:00:00 2001 From: Thomas Erlang Date: Sun, 19 May 2024 20:17:23 +0200 Subject: [PATCH] Upgraded packages --- pyproject.toml | 40 ++ requirements.txt | 28 +- seplis_play_server/database.py | 36 +- seplis_play_server/routes/subtitle_file.py | 26 +- seplis_play_server/scanners/base.py | 4 +- seplis_play_server/scanners/episodes.py | 313 +++++++----- seplis_play_server/scanners/movies.py | 188 ++++--- seplis_play_server/testbase.py | 22 +- seplis_play_server/transcoders/video.py | 461 ++++++++++++------ tests/conftest.py | 25 + .../scanners/test_episodes.py | 308 +++++++----- .../scanners/test_movies.py | 79 ++- 12 files changed, 969 insertions(+), 561 deletions(-) create mode 100644 pyproject.toml create mode 100644 tests/conftest.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d9d3d32 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "seplis_play_server" +version = "2.0" +description = "" +dependencies = [ + "PyYAML==6.0.1", + "pydantic==2.7.1", + "pydantic-settings==2.2.1", + "sqlalchemy[asyncio]==2.0.30", + "alembic==1.13.1", + "sentry-sdk==2.2.0", + "aiomysql==0.2.0", + "aiosqlite==0.19.0", + "fastapi==0.111.0", + "pyjwt==2.8.0", + "uvicorn==0.29.0", + "click==8.1.7", + "pytest==8.2.0", + "pytest_asyncio==0.23.7", + "httpx==0.27.0", + "respx==0.21.1", + "orjson==3.10.3", + "guessit==3.8.0", + "watchfiles==0.21.0", + "aiofile==3.8.8", + "iso639-lang==2.2.3", +] + +[build-system] +build-backend = "flit_core.buildapi" +requires = ["flit_core>=3.2,<4"] + +[tool.ruff] +ignore = ["E712"] + +[tool.ruff.format] +quote-style = "single" + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] diff --git a/requirements.txt b/requirements.txt index 3723bbd..5bd13d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,21 @@ PyYAML==6.0.1 -pydantic==2.5.2 -pydantic-settings==2.0.3 -sqlalchemy[asyncio]==2.0.23 -alembic==1.12.1 -sentry-sdk==1.34.0 +pydantic==2.7.1 +pydantic-settings==2.2.1 +sqlalchemy[asyncio]==2.0.30 +alembic==1.13.1 +sentry-sdk==2.2.0 aiomysql==0.2.0 aiosqlite==0.19.0 -fastapi==0.109.2 +fastapi==0.111.0 pyjwt==2.8.0 -uvicorn==0.23.2 +uvicorn==0.29.0 click==8.1.7 -pytest==7.4.3 -pytest_asyncio==0.21.1 -httpx==0.25.1 -respx==0.20.2 -orjson==3.9.10 -guessit==3.7.1 +pytest==8.2.0 +pytest_asyncio==0.23.7 +httpx==0.27.0 +respx==0.21.1 +orjson==3.10.3 +guessit==3.8.0 watchfiles==0.21.0 aiofile==3.8.8 -iso639-lang==2.1.0 \ No newline at end of file +iso639-lang==2.2.3 \ No newline at end of file diff --git a/seplis_play_server/database.py b/seplis_play_server/database.py index 23ae960..5cfa8b8 100644 --- a/seplis_play_server/database.py +++ b/seplis_play_server/database.py @@ -1,43 +1,51 @@ -from sqlalchemy.orm import sessionmaker -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.ext.asyncio import ( + AsyncSession, + create_async_engine, + async_sessionmaker, + AsyncEngine, +) from seplis_play_server import config, utils import os.path import alembic.config from alembic import command + def get_config(): cfg = alembic.config.Config( - os.path.dirname( - os.path.abspath(__file__) - )+'/alembic.ini' + os.path.dirname(os.path.abspath(__file__)) + "/alembic.ini" ) - cfg.set_main_option('script_location', 'seplis_play_server:migration') - cfg.set_main_option('url', config.database) + cfg.set_main_option("script_location", "seplis_play_server:migration") + cfg.set_main_option("url", config.database) return cfg + def upgrade(): cfg = get_config() - command.upgrade(cfg, 'head') + command.upgrade(cfg, "head") class Database: - def __init__(self): - self.engine = None - self.session = None + self.engine: AsyncEngine + self.session: async_sessionmaker def setup(self): self.engine = create_async_engine( - config.database.replace('mysqldb', 'aiomysql').replace('pymysql', 'aiomysql').replace('sqlite:', 'sqlite+aiosqlite:'), + config.database.replace("mysqldb", "aiomysql") + .replace("pymysql", "aiomysql") + .replace("sqlite:", "sqlite+aiosqlite:"), echo=False, pool_recycle=3599, pool_pre_ping=True, json_serializer=lambda obj: utils.json_dumps(obj), json_deserializer=lambda s: utils.json_loads(s), ) - self.session = sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession) + self.session = async_sessionmaker( + self.engine, expire_on_commit=False, class_=AsyncSession + ) async def close(self): await self.engine.dispose() -database = Database() \ No newline at end of file + +database = Database() diff --git a/seplis_play_server/routes/subtitle_file.py b/seplis_play_server/routes/subtitle_file.py index 6749a1e..f4a01b6 100644 --- a/seplis_play_server/routes/subtitle_file.py +++ b/seplis_play_server/routes/subtitle_file.py @@ -1,27 +1,25 @@ +from typing import Annotated from fastapi import APIRouter, HTTPException, Response, Depends -from pydantic import constr +from pydantic import StringConstraints from ..transcoders.subtitle import get_subtitle_file, get_subtitle_file_from_external from ..dependencies import get_metadata router = APIRouter() -@router.get('/subtitle-file') + +@router.get("/subtitle-file") async def download_subtitle( - lang: constr(min_length=1), + lang: Annotated[str, StringConstraints(min_length=1)], offset: int | float = 0, - metadata=Depends(get_metadata) -): - if int(lang.split(':')[1]) < 1000: - sub = await get_subtitle_file( - metadata=metadata, - lang=lang, - offset=offset - ) + metadata=Depends(get_metadata), +): + if int(lang.split(":")[1]) < 1000: + sub = await get_subtitle_file(metadata=metadata, lang=lang, offset=offset) else: sub = await get_subtitle_file_from_external( - id_=int(lang.split(':')[1])-1000, + id_=int(lang.split(":")[1]) - 1000, offset=offset, ) if not sub: - raise HTTPException(500, 'Unable retrive subtitle file') - return Response(content=sub, media_type='text/vtt') \ No newline at end of file + raise HTTPException(500, "Unable retrive subtitle file") + return Response(content=sub, media_type="text/vtt") diff --git a/seplis_play_server/scanners/base.py b/seplis_play_server/scanners/base.py index 1005812..ebc76bf 100644 --- a/seplis_play_server/scanners/base.py +++ b/seplis_play_server/scanners/base.py @@ -1,5 +1,7 @@ import asyncio -import os, os.path, subprocess +import os +import os.path +import subprocess from datetime import datetime, timezone from seplis_play_server import config, utils, logger diff --git a/seplis_play_server/scanners/episodes.py b/seplis_play_server/scanners/episodes.py index 0b5bff1..5b15181 100644 --- a/seplis_play_server/scanners/episodes.py +++ b/seplis_play_server/scanners/episodes.py @@ -1,20 +1,29 @@ import asyncio -import os.path, re +import os.path +import re +from datetime import date, datetime, timezone + import sqlalchemy as sa from guessit import guessit -from datetime import date, datetime, timezone -from seplis_play_server import constants, models, config, utils, logger, schemas + +from seplis_play_server import config, constants, logger, models, schemas, utils from seplis_play_server.client import client from seplis_play_server.database import database from seplis_play_server.scanners.subtitles import Subtitle_scan + from .base import Play_scan class Episode_scan(Play_scan): - SCANNER_NAME = 'Episodes' - def __init__(self, scan_path: str, make_thumbnails: bool = False, cleanup_mode = False, parser = 'internal'): + def __init__( + self, + scan_path: str, + make_thumbnails: bool = False, + cleanup_mode=False, + parser='internal', + ): super().__init__(scan_path, make_thumbnails, cleanup_mode, parser) self.series_id = Series_id_lookup(scanner=self) self.episode_number = Episode_number_lookup(scanner=self) @@ -24,14 +33,16 @@ def __init__(self, scan_path: str, make_thumbnails: bool = False, cleanup_mode = def parse(self, filename): result = None if self.parser == 'guessit': - result = self.guessit_parse_file_name(filename) + result = self.guessit_parse_file_name(filename) if self.parser == 'internal': result = self.regex_parse_file_name(filename) if not result: - logger.info(f'{filename} doesn\'t look like a episode') + logger.info(f"{filename} doesn't look like a episode") return result - async def episode_series_id_lookup(self, episode: schemas.Parsed_file_episode, path: str = None): + async def episode_series_id_lookup( + self, episode: schemas.Parsed_file_episode, path: str = None + ): if episode.title in self.not_found_series: return False logger.debug(f'Looking for a series with title: "{episode.title}"') @@ -45,11 +56,13 @@ async def episode_series_id_lookup(self, episode: schemas.Parsed_file_episode, p logger.info(f'No series found for "{episode.title}" ({path})') return False - async def episode_number_lookup(self, episode: schemas.Parsed_file_episode, path: str = None): - ''' + async def episode_number_lookup( + self, episode: schemas.Parsed_file_episode, path: str = None + ): + """ Tries to lookup the episode number of the episode. Sets the number in the episode object if successful. - ''' + """ if not episode.series_id: return if episode.episode_number: @@ -59,22 +72,26 @@ async def episode_number_lookup(self, episode: schemas.Parsed_file_episode, path return logger.debug(f'[series-{episode.series_id}] Looking for episode {value}') number = await self.episode_number.lookup(episode) - if number: + if number: logger.debug(f'[episodes-{episode.series_id}-{number}] Found episode') episode.episode_number = number return True else: - logger.info(f'[series-{episode.series_id}] No episode found for {value} ({path})') + logger.info( + f'[series-{episode.series_id}] No episode found for {value} ({path})' + ) return False async def save_item(self, item: schemas.Parsed_file_episode, path: str): if not os.path.exists(path): - logger.debug(f'Path doesn\'t exist any longer: {path}') + logger.debug(f"Path doesn't exist any longer: {path}") return async with database.session() as session: - ep = await session.scalar(sa.select(models.Episode).where( - models.Episode.path == path, - )) + ep = await session.scalar( + sa.select(models.Episode).where( + models.Episode.path == path, + ) + ) if ep: item.series_id = ep.series_id item.episode_number = ep.number @@ -88,79 +105,123 @@ async def save_item(self, item: schemas.Parsed_file_episode, path: str): if not await self.episode_number_lookup(item, path): return False try: + import logging + + logging.error('Saving episode') metadata = await self.get_metadata(path) if ep: - sql = sa.update(models.Episode).where( - models.Episode.path == path, - ).values({ - models.Episode.meta_data: metadata, - models.Episode.modified_time: modified_time, - }) + sql = ( + sa.update(models.Episode) + .where( + models.Episode.path == path, + ) + .values( + { + models.Episode.meta_data: metadata, + models.Episode.modified_time: modified_time, + } + ) + ) else: - sql = sa.insert(models.Episode).values({ - models.Episode.series_id: item.series_id, - models.Episode.number: item.episode_number, - models.Episode.path: path, - models.Episode.meta_data: metadata, - models.Episode.modified_time: modified_time, - }) + sql = sa.insert(models.Episode).values( + { + models.Episode.series_id: item.series_id, + models.Episode.number: item.episode_number, + models.Episode.path: path, + models.Episode.meta_data: metadata, + models.Episode.modified_time: modified_time, + } + ) await session.execute(sql) await session.commit() await self.add_to_index( - series_id=item.series_id, + series_id=item.series_id, episode_number=item.episode_number, created_at=modified_time, ) - logger.info(f'[episode-{item.series_id}-{item.episode_number}] Saved {path}') + logger.info( + f'[episode-{item.series_id}-{item.episode_number}] Saved {path}' + ) except Exception as e: - logger.exception(f'[episode-{item.series_id}-{item.episode_number}]: {str(e)}') - else: - logger.debug(f'[episode-{item.series_id}-{item.episode_number}] Nothing changed for {path}') + logger.exception( + f'[episode-{item.series_id}-{item.episode_number}]: {str(e)}' + ) + else: + logger.debug( + f'[episode-{item.series_id}-{item.episode_number}] Nothing changed for {path}' + ) if self.make_thumbnails: - asyncio.create_task(self.thumbnails(f'episode-{item.series_id}-{item.episode_number}', path)) + asyncio.create_task( + self.thumbnails( + f'episode-{item.series_id}-{item.episode_number}', path + ) + ) return True - async def add_to_index(self, series_id: int, episode_number: int, created_at: datetime = None): + async def add_to_index( + self, series_id: int, episode_number: int, created_at: datetime = None + ): if self.cleanup_mode: return if not config.server_id: - logger.warn(f'[episode-{series_id}-{episode_number}] No server_id specified') + logger.warn( + f'[episode-{series_id}-{episode_number}] No server_id specified' + ) return - r = await client.patch(f'/2/play-servers/{config.server_id}/episodes', data=utils.json_dumps([ - schemas.Play_server_episode_create( - series_id=series_id, - episode_number=episode_number, - created_at=created_at or datetime.now(tz=timezone.utc), - ) - ]), headers={ + r = await client.patch( + f'/2/play-servers/{config.server_id}/episodes', + data=utils.json_dumps( + [ + schemas.Play_server_episode_create( + series_id=series_id, + episode_number=episode_number, + created_at=created_at or datetime.now(tz=timezone.utc), + ) + ] + ), + headers={ 'Authorization': f'Secret {config.secret}', 'Content-Type': 'application/json', - } + }, ) if r.status_code >= 400: - logger.error(f'[episode-{series_id}-{episode_number}] Faild to add the episode to the play server index: {r.content}') + logger.error( + f'[episode-{series_id}-{episode_number}] Failed to add the episode to the play server index: {r.content}' + ) else: - logger.info(f'[episode-{series_id}-{episode_number}] Added to play server index ({config.server_id})') + logger.info( + f'[episode-{series_id}-{episode_number}] Added to play server index ({config.server_id})' + ) async def delete_path(self, path): async with database.session() as session: - episode = await session.scalar(sa.select(models.Episode).where( - models.Episode.path == path, - )) - if episode: - await session.execute(sa.delete(models.Episode).where( + episode = await session.scalar( + sa.select(models.Episode).where( models.Episode.path == path, - )) + ) + ) + if episode: + await session.execute( + sa.delete(models.Episode).where( + models.Episode.path == path, + ) + ) await session.commit() - await self.delete_from_index(series_id=episode.series_id, episode_number=episode.number, session=session) + await self.delete_from_index( + series_id=episode.series_id, + episode_number=episode.number, + session=session, + ) - logger.info(f'[episode-{episode.series_id}-{episode.number}] Deleted: {path}') + logger.info( + f'[episode-{episode.series_id}-{episode.number}] Deleted: {path}' + ) return True return False @@ -168,35 +229,40 @@ async def delete_from_index(self, series_id: int, episode_number: int, session): if self.cleanup_mode: return - m = await session.scalar(sa.select(models.Episode).where( - models.Episode.series_id == series_id, - models.Episode.number == episode_number, - )) + m = await session.scalar( + sa.select(models.Episode).where( + models.Episode.series_id == series_id, + models.Episode.number == episode_number, + ) + ) if m: return - + if not config.server_id: - logger.warn(f'[episode-{series_id}-{episode_number}] No server_id specified') + logger.warn( + f'[episode-{series_id}-{episode_number}] No server_id specified' + ) return - r = await client.delete(f'/2/play-servers/{config.server_id}/series/{series_id}/episodes/{episode_number}', - headers={ - 'Authorization': f'Secret {config.secret}' - } + r = await client.delete( + f'/2/play-servers/{config.server_id}/series/{series_id}/episodes/{episode_number}', + headers={'Authorization': f'Secret {config.secret}'}, ) if r.status_code >= 400: - logger.error(f'[episode-{series_id}-{episode_number}] Faild to remove the episode from the play server index: {r.content}') + logger.error( + f'[episode-{series_id}-{episode_number}] Faild to remove the episode from the play server index: {r.content}' + ) else: - logger.info(f'[episode-{series_id}-{episode_number}] Removed from play server index') + logger.info( + f'[episode-{series_id}-{episode_number}] Removed from play server index' + ) - def regex_parse_file_name(self, filename: str) -> schemas.Parsed_file_episode: + def regex_parse_file_name(self, filename: str): result = schemas.Parsed_file_episode() for pattern in constants.SERIES_FILENAME_PATTERNS: try: match = re.match( - pattern, - os.path.basename(filename), - re.VERBOSE | re.IGNORECASE + pattern, os.path.basename(filename), re.VERBOSE | re.IGNORECASE ) if not match: continue @@ -204,7 +270,7 @@ def regex_parse_file_name(self, filename: str) -> schemas.Parsed_file_episode: fields = match.groupdict().keys() if 'file_title' not in fields: continue - + result.title = match.group('file_title').strip().lower() if 'season' in fields: @@ -227,21 +293,29 @@ def regex_parse_file_name(self, filename: str) -> schemas.Parsed_file_episode: result.episode_number = int(match.group('absolute_number')) if 'year' in fields and 'month' in fields and 'day' in fields: - result.date = date(int(match.group('year')), int(match.group('month')), int(match.group('day'))) - return result + result.date = date( + int(match.group('year')), + int(match.group('month')), + int(match.group('day')), + ) except re.error as error: logger.exception(f'episode parse re error: {error}') - except: + except Exception: logger.exception(f'episode parse pattern: {pattern}') + return result def guessit_parse_file_name(self, filename: str) -> schemas.Parsed_file_episode: - d = guessit(filename, { - 'type': 'episode', - 'episode_prefer_number': True, - 'excludes': ['country', 'language'], - }) + d = guessit( + filename, + { + 'type': 'episode', + 'episode_prefer_number': True, + 'excludes': ['country', 'language'], + 'no_user_config': 'true', + }, + ) + result = schemas.Parsed_file_episode() if d and d.get('title'): - result = schemas.Parsed_file_episode() result.title = d['title'].strip().lower() if d.get('year'): result.title += f' ({d["year"]})' @@ -253,32 +327,35 @@ def guessit_parse_file_name(self, filename: str) -> schemas.Parsed_file_episode: result.episode_number = d['episode'] if d.get('date'): result.date = d['date'] - return result else: - logger.info(f'{filename} doesn\'t look like an episode') + logger.info(f"{filename} doesn't look like an episode") + return result async def get_paths_matching_base_path(self, base_path: str): async with database.session() as session: - results = await session.scalars(sa.select(models.Episode.path).where( - models.Episode.path.like(f'{base_path}%'), - )) + results = await session.scalars( + sa.select(models.Episode.path).where( + models.Episode.path.like(f'{base_path}%'), + ) + ) return [r for r in results] + class Series_id_lookup(object): - '''Used to lookup a series id by it's title. + """Used to lookup a series id by it's title. The result will be cached in the local db. - ''' + """ def __init__(self, scanner): self.scanner = scanner async def lookup(self, file_title: str): - ''' + """ Tries to find the series on SEPLIS by it's title. :param file_title: str :returns: int - ''' + """ series_id = await self.db_lookup(file_title) if series_id: return series_id @@ -298,26 +375,32 @@ async def lookup(self, file_title: str): async def db_lookup(self, file_title: str): async with database.session() as session: - series = await session.scalar(sa.select(models.Series_id_lookup).where( - models.Series_id_lookup.file_title == file_title, - )) + series = await session.scalar( + sa.select(models.Series_id_lookup).where( + models.Series_id_lookup.file_title == file_title, + ) + ) if not series or not series.series_id: return return series.series_id async def web_lookup(self, file_title: str): - r = await client.get('/2/search', params={ - 'title': file_title, - 'type': 'series', - }) + r = await client.get( + '/2/search', + params={ + 'title': file_title, + 'type': 'series', + }, + ) r.raise_for_status() return r.json() + class Episode_number_lookup(object): - '''Used to lookup an episode's number from the season and episode or + """Used to lookup an episode's number from the season and episode or an air date. Stores the result in the local db. - ''' + """ def __init__(self, scanner): self.scanner = scanner @@ -334,23 +417,27 @@ async def lookup(self, episode: schemas.Parsed_file_episode): if not number: return async with database.session() as session: - await session.execute(sa.insert(models.Episode_number_lookup).values( - series_id=episode.series_id, - lookup_type=1, - lookup_value=self.get_lookup_value(episode), - number=number, - )) + await session.execute( + sa.insert(models.Episode_number_lookup).values( + series_id=episode.series_id, + lookup_type=1, + lookup_value=self.get_lookup_value(episode), + number=number, + ) + ) await session.commit() return number async def db_lookup(self, episode): async with database.session() as session: value = self.get_lookup_value(episode) - e = await session.scalar(sa.select(models.Episode_number_lookup.number).where( - models.Episode_number_lookup.series_id == episode.series_id, - models.Episode_number_lookup.lookup_type == 1, - models.Episode_number_lookup.lookup_value == value, - )) + e = await session.scalar( + sa.select(models.Episode_number_lookup.number).where( + models.Episode_number_lookup.series_id == episode.series_id, + models.Episode_number_lookup.lookup_type == 1, + models.Episode_number_lookup.lookup_value == value, + ) + ) if not e: return return e @@ -379,7 +466,7 @@ async def web_lookup(self, episode): raise Exception('Unknown parsed episode object') r = await client.get(f'/2/series/{episode.series_id}/episodes', params=params) r.raise_for_status() - episodes = schemas.Page_cursor_result[schemas.Episode].parse_obj(r.json()) + episodes = schemas.Page_cursor_result[schemas.Episode].model_validate(r.json()) if not episodes.items: return - return episodes.items[0].number \ No newline at end of file + return episodes.items[0].number diff --git a/seplis_play_server/scanners/movies.py b/seplis_play_server/scanners/movies.py index 062b2fa..3225060 100644 --- a/seplis_play_server/scanners/movies.py +++ b/seplis_play_server/scanners/movies.py @@ -1,44 +1,56 @@ -import os.path import asyncio -import sqlalchemy as sa +import os.path from datetime import datetime, timezone -from seplis_play_server import config, utils, logger, models, schemas + +import sqlalchemy as sa +from guessit import guessit + +from seplis_play_server import config, logger, models, schemas, utils from seplis_play_server.client import client from seplis_play_server.database import database -from guessit import guessit + from .base import Play_scan class Movie_scan(Play_scan): - SCANNER_NAME = 'Movies' def parse(self, filename): - d = guessit(filename, { - 'type': 'movie', - 'excludes': ['country', 'language', 'film'], - }) + d = guessit( + filename, + { + 'type': 'movie', + 'excludes': ['country', 'language', 'film', 'part'], + 'no_user_config': 'true', + }, + ) if d and d.get('title'): t = d['title'] if d.get('part'): t += f' Part {d["part"]}' if d.get('year'): t += f" ({d['year']})" - return t - logger.info(f'{filename} doesn\'t look like a movie') + return t + logger.info(f"{filename} doesn't look like a movie") async def save_item(self, item: str, path: str): if not os.path.exists(path): - logger.debug(f'Path doesn\'t exist any longer: {path}') + logger.debug(f"Path doesn't exist any longer: {path}") return async with database.session() as session: - movie = await session.scalar(sa.select(models.Movie).where( - models.Movie.path == path, - )) + movie = await session.scalar( + sa.select(models.Movie).where( + models.Movie.path == path, + ) + ) movie_id = movie.movie_id if movie else None modified_time = self.get_file_modified_time(path) - - if not movie or (movie.modified_time != modified_time) or not movie.meta_data: + + if ( + not movie + or (movie.modified_time != modified_time) + or not movie.meta_data + ): if not movie_id: movie_id = await self.lookup(item) if not movie_id: @@ -50,20 +62,28 @@ async def save_item(self, item: str, path: str): return if movie: - sql = sa.update(models.Movie).where( - models.Movie.path == path, - ).values({ - models.Movie.movie_id: movie_id, - models.Movie.meta_data: metadata, - models.Movie.modified_time: modified_time, - }) + sql = ( + sa.update(models.Movie) + .where( + models.Movie.path == path, + ) + .values( + { + models.Movie.movie_id: movie_id, + models.Movie.meta_data: metadata, + models.Movie.modified_time: modified_time, + } + ) + ) else: - sql = sa.insert(models.Movie).values({ - models.Movie.movie_id: movie_id, - models.Movie.path: path, - models.Movie.meta_data: metadata, - models.Movie.modified_time: modified_time, - }) + sql = sa.insert(models.Movie).values( + { + models.Movie.movie_id: movie_id, + models.Movie.path: path, + models.Movie.meta_data: metadata, + models.Movie.modified_time: modified_time, + } + ) await session.execute(sql) await session.commit() @@ -85,31 +105,46 @@ async def add_to_index(self, movie_id: int, created_at: datetime = None): if not config.server_id: logger.warn(f'[movie-{movie_id}] No server_id specified') - r = await client.patch(f'/2/play-servers/{config.server_id}/movies', data=utils.json_dumps([ - schemas.Play_server_movie_create( - movie_id=movie_id, - created_at=created_at or datetime.now(tz=timezone.utc) - ) - ]), headers={ - 'Authorization': f'Secret {config.secret}', - 'Content-Type': 'application/json', - }) + r = await client.patch( + f'/2/play-servers/{config.server_id}/movies', + data=utils.json_dumps( + [ + schemas.Play_server_movie_create( + movie_id=movie_id, + created_at=created_at or datetime.now(tz=timezone.utc), + ) + ] + ), + headers={ + 'Authorization': f'Secret {config.secret}', + 'Content-Type': 'application/json', + }, + ) if r.status_code >= 400: - logger.error(f'[movie-{movie_id}] Faild to add the movie to the play server index ({config.server_id}): {r.content}') + logger.error( + f'[movie-{movie_id}] Faild to add the movie to the play server index ({config.server_id}): {r.content}' + ) else: - logger.info(f'[movie-{movie_id}] Added to play server index ({config.server_id})') + logger.info( + f'[movie-{movie_id}] Added to play server index ({config.server_id})' + ) async def lookup(self, title: str): logger.debug(f'Looking for a movie with title: "{title}"') async with database.session() as session: - movie = await session.scalar(sa.select(models.Movie_id_lookup).where( - models.Movie_id_lookup.file_title == title, - )) + movie = await session.scalar( + sa.select(models.Movie_id_lookup).where( + models.Movie_id_lookup.file_title == title, + ) + ) if not movie: - r = await client.get('/2/search', params={ - 'title': title, - 'type': 'movie', - }) + r = await client.get( + '/2/search', + params={ + 'title': title, + 'type': 'movie', + }, + ) r.raise_for_status() movies = r.json() if not movies: @@ -117,51 +152,60 @@ async def lookup(self, title: str): logger.debug(f'[movie-{movies[0]["id"]}] Found: {movies[0]["title"]}') movie = models.Movie_id_lookup( file_title=title, - movie_title=movies[0]["title"], - movie_id=movies[0]["id"], + movie_title=movies[0]['title'], + movie_id=movies[0]['id'], updated_at=datetime.now(tz=timezone.utc), ) await session.merge(movie) await session.commit() return movie.movie_id - else: - logger.debug(f'[movie-{movie.movie_id}] Found from cache: {movie.movie_title}') + else: + logger.debug( + f'[movie-{movie.movie_id}] Found from cache: {movie.movie_title}' + ) return movie.movie_id async def delete_path(self, path): async with database.session() as session: - movie_id = await session.scalar(sa.select(models.Movie.movie_id).where( - models.Movie.path == path, - )) - if movie_id: - await session.execute(sa.delete(models.Movie).where( + movie_id = await session.scalar( + sa.select(models.Movie.movie_id).where( models.Movie.path == path, - )) + ) + ) + if movie_id: + await session.execute( + sa.delete(models.Movie).where( + models.Movie.path == path, + ) + ) await session.commit() await self.delete_from_index(movie_id=movie_id, session=session) logger.info(f'[movie-{movie_id}] Deleted: {path}') return True - + return False async def delete_from_index(self, movie_id: int, session): if self.cleanup_mode: return if config.server_id: - m = await session.scalar(sa.select(models.Movie).where( - models.Movie.movie_id == movie_id, - )) + m = await session.scalar( + sa.select(models.Movie).where( + models.Movie.movie_id == movie_id, + ) + ) if m: return - r = await client.delete(f'/2/play-servers/{config.server_id}/movies/{movie_id}', - headers={ - 'Authorization': f'Secret {config.secret}' - } + r = await client.delete( + f'/2/play-servers/{config.server_id}/movies/{movie_id}', + headers={'Authorization': f'Secret {config.secret}'}, ) if r.status_code >= 400: - logger.error(f'[movie-{movie_id}] Failed to add the movie to the play server index: {r.content}') + logger.error( + f'[movie-{movie_id}] Failed to add the movie to the play server index: {r.content}' + ) else: logger.info(f'[movie-{movie_id}] Deleted from play server index') else: @@ -169,7 +213,9 @@ async def delete_from_index(self, movie_id: int, session): async def get_paths_matching_base_path(self, base_path): async with database.session() as session: - results = await session.scalars(sa.select(models.Movie.path).where( - models.Movie.path.like(f'{base_path}%') - )) - return [r for r in results] \ No newline at end of file + results = await session.scalars( + sa.select(models.Movie.path).where( + models.Movie.path.like(f'{base_path}%') + ) + ) + return [r for r in results] diff --git a/seplis_play_server/testbase.py b/seplis_play_server/testbase.py index a8f168c..190838c 100644 --- a/seplis_play_server/testbase.py +++ b/seplis_play_server/testbase.py @@ -1,24 +1,4 @@ -import pytest_asyncio -import tempfile -from seplis_play_server import config -from seplis_play_server.logger import set_logger - - def run_file(file_): import subprocess - subprocess.call(['pytest', '--tb=short', str(file_)]) - -@pytest_asyncio.fixture(scope='function') -async def play_db_test(): - from seplis_play_server.database import database - from seplis_play_server import scan - set_logger('play_test') - config.test = True - config.server_id = '123' - with tempfile.TemporaryDirectory() as dir: - config.database = f'sqlite:///{dir}/db.sqlite' - scan.upgrade_scan_db() - database.setup() - yield database - await database.close() \ No newline at end of file + subprocess.call(["pytest", "--tb=short", str(file_)]) diff --git a/seplis_play_server/transcoders/video.py b/seplis_play_server/transcoders/video.py index c9ab7ad..7e7d23c 100644 --- a/seplis_play_server/transcoders/video.py +++ b/seplis_play_server/transcoders/video.py @@ -1,59 +1,89 @@ +import asyncio +import os +import shutil +import sys +import uuid from decimal import Decimal -import os, asyncio, sys, shutil, uuid +from typing import Annotated, Dict, Literal, Optional + from fastapi import Query -from typing import Dict, Literal, Optional, Annotated -from pydantic import BaseModel, ConfigDict, constr, conint, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + field_validator, +) from pydantic.dataclasses import dataclass + from seplis_play_server import config, logger + @dataclass class Transcode_settings: source_index: int - play_id: constr(min_length=1) - supported_video_codecs: Annotated[list[constr(min_length=1)], Query()] - supported_audio_codecs: Annotated[list[constr(min_length=1)], Query()] + play_id: Annotated[str, Query(min_length=1)] + supported_video_codecs: Annotated[ + list[Annotated[str, Query(min_length=1)]], Query() + ] + supported_audio_codecs: Annotated[ + list[Annotated[str, Query(min_length=1)]], Query() + ] format: Literal['pipe', 'hls', 'hls.js', 'dash'] transcode_video_codec: Literal['h264', 'hevc', 'vp9'] transcode_audio_codec: Literal['aac', 'opus', 'dts', 'flac', 'mp3'] - session: Annotated[constr(min_length=36), Query(default_factory=lambda: str(uuid.uuid4()))] - supported_hdr_formats: list[Literal['hdr10', 'hlg', 'dovi']] = Query(default=[]) - supported_video_color_bit_depth: conint(ge=8) | constr(max_length=0) = 10 - start_time: Optional[Decimal] | constr(max_length=0) = 0 - start_segment: Optional[int] | constr(max_length=0) = None - audio_lang: Optional[str] = None - max_audio_channels: Optional[int] | constr(max_length=0) = None - max_width: Optional[int] | constr(max_length=0) = None - max_video_bitrate: Optional[int] | constr(max_length=0) = None - supported_video_containers: Annotated[list[constr(min_length=1)], Query()] = Query(default=['mp4']) + session: Annotated[ + str, Query(default_factory=lambda: str(uuid.uuid4()), min_length=32) + ] + supported_video_containers: Annotated[ + list[Annotated[str, Query(max_length=1)]], + Query(default_factory=lambda: ['mp4']), + ] + supported_hdr_formats: Annotated[ + list[Literal['hdr10', 'hlg', 'dovi']], Query(default_factory=lambda: []) + ] + supported_video_color_bit_depth: ( + Annotated[int, Query(ge=8)] | Annotated[str, Query(max_length=0)] + ) = 10 + start_time: Annotated[Decimal, Query()] | Annotated[str, Query(max_length=0)] = ( + Decimal(0) + ) + start_segment: int | Annotated[str, Query(max_length=0)] | None = None + audio_lang: str | None = None + max_audio_channels: int | None | Annotated[str, Query(max_length=0)] = None + max_width: int | None | Annotated[str, Query(max_length=0)] = None + max_video_bitrate: int | None | Annotated[str, Query(max_length=0)] = None client_can_switch_audio_track: bool = False - + @field_validator( - 'supported_video_codecs', - 'supported_audio_codecs', - 'supported_hdr_formats', + 'supported_video_codecs', + 'supported_audio_codecs', + 'supported_hdr_formats', 'supported_video_containers', ) @classmethod def comma_string(cls, v): - l = [] + ll = [] for a in v: - l.extend([s.strip() for s in a.split(',')]) - return l - + ll.extend([s.strip() for s in a.split(',')]) + return ll + def to_args_dict(self): from pydantic import RootModel - settings_dict = RootModel[Transcode_settings](self).model_dump(exclude_none=True, exclude_unset=True) + + settings_dict = RootModel[Transcode_settings](self).model_dump( + exclude_none=True, exclude_unset=True + ) for key in settings_dict: if isinstance(settings_dict[key], list): settings_dict[key] = ','.join(settings_dict[key]) return settings_dict - + class Video_color(BaseModel): range: str range_type: str + class Session_model(BaseModel): process: asyncio.subprocess.Process transcode_folder: Optional[str] | None = None @@ -63,6 +93,7 @@ class Session_model(BaseModel): arbitrary_types_allowed=True, ) + sessions: Dict[str, Session_model] = {} codecs_to_library = { @@ -77,12 +108,13 @@ class Session_model(BaseModel): 'mp3': 'libmp3lame', } + class Stream_index(BaseModel): index: int group_index: int -class Transcoder: +class Transcoder: def __init__(self, settings: Transcode_settings, metadata: Dict): self.settings = settings self.metadata = metadata @@ -96,16 +128,24 @@ def __init__(self, settings: Transcode_settings, metadata: Dict): self.can_copy_audio = self.get_can_copy_audio() self.video_output_codec_lib = None self.audio_output_codec_lib = None - self.video_output_codec = self.video_input_codec if self.can_copy_video else self.settings.transcode_video_codec - self.audio_output_codec = self.audio_input_codec if self.can_copy_audio else self.settings.transcode_audio_codec + self.video_output_codec = ( + self.video_input_codec + if self.can_copy_video + else self.settings.transcode_video_codec + ) + self.audio_output_codec = ( + self.audio_input_codec + if self.can_copy_audio + else self.settings.transcode_audio_codec + ) self.ffmpeg_args = None self.transcode_folder = None - + async def start(self, send_data_callback=None) -> bool | bytes: self.transcode_folder = self.create_transcode_folder() await self.set_ffmpeg_args() - + args = to_subprocess_arguments(self.ffmpeg_args) logger.debug(f'[{self.settings.session}] FFmpeg start args: {" ".join(args)}') self.process = await asyncio.create_subprocess_exec( @@ -116,13 +156,17 @@ async def start(self, send_data_callback=None) -> bool | bytes: stderr=asyncio.subprocess.DEVNULL, ) self.register_session() - + logger.debug(f'[{self.settings.session}] Waiting for media') ready = False try: - ready = await asyncio.wait_for(self.wait_for_media(), timeout=60 if not config.debug else 20) + ready = await asyncio.wait_for( + self.wait_for_media(), timeout=60 if not config.debug else 20 + ) except asyncio.TimeoutError: - logger.error(f'[{self.settings.session}] Failed to create media, gave up waiting') + logger.error( + f'[{self.settings.session}] Failed to create media, gave up waiting' + ) try: self.process.terminate() except: @@ -139,7 +183,7 @@ def ffmpeg_change_args(self) -> None: async def wait_for_media(self) -> bool: pass - + def close(self) -> None: pass @@ -159,9 +203,7 @@ def register_session(self): sessions[self.settings.session].process = self.process sessions[self.settings.session].call_later.cancel() sessions[self.settings.session].call_later = loop.call_later( - config.session_timeout, - close_session_callback, - self.settings.session + config.session_timeout, close_session_callback, self.settings.session ) else: logger.info(f'[{self.settings.session}] Registered') @@ -171,27 +213,31 @@ def register_session(self): call_later=loop.call_later( config.session_timeout, close_session_callback, - self.settings.session + self.settings.session, ), ) async def set_ffmpeg_args(self): - self.ffmpeg_args = [ + self.ffmpeg_args = [ {'-analyzeduration': '200M'}, ] if self.can_copy_video: self.ffmpeg_args.append({'-fflags': '+genpts'}) self.set_hardware_decoder() if self.settings.start_time: - self.ffmpeg_args.append({'-ss': str(self.settings.start_time.quantize(Decimal('0.000')))}) - self.ffmpeg_args.extend([ - {'-i': f"file:{self.metadata['format']['filename']}"}, - {'-map_metadata': '-1'}, - {'-map_chapters': '-1'}, - {'-threads': '0'}, - {'-max_delay': '5000000'}, - {'-max_muxing_queue_size': '2048'}, - ]) + self.ffmpeg_args.append( + {'-ss': str(self.settings.start_time.quantize(Decimal('0.000')))} + ) + self.ffmpeg_args.extend( + [ + {'-i': f"file:{self.metadata['format']['filename']}"}, + {'-map_metadata': '-1'}, + {'-map_chapters': '-1'}, + {'-threads': '0'}, + {'-max_delay': '5000000'}, + {'-max_muxing_queue_size': '2048'}, + ] + ) self.set_video() self.set_audio() self.ffmpeg_extend_args() @@ -199,25 +245,29 @@ async def set_ffmpeg_args(self): def set_hardware_decoder(self): if not config.ffmpeg_hwaccel_enabled: return - + if self.can_copy_video: return if config.ffmpeg_hwaccel == 'qsv': - self.ffmpeg_args.extend([ - {'-init_hw_device': f'vaapi=va:'}, - {'-init_hw_device': 'qsv=qs@va'}, - {'-filter_hw_device': 'qs'}, - {'-hwaccel': 'vaapi'}, - {'-hwaccel_output_format': 'vaapi'}, - ]) + self.ffmpeg_args.extend( + [ + {'-init_hw_device': 'vaapi=va:'}, + {'-init_hw_device': 'qsv=qs@va'}, + {'-filter_hw_device': 'qs'}, + {'-hwaccel': 'vaapi'}, + {'-hwaccel_output_format': 'vaapi'}, + ] + ) elif config.ffmpeg_hwaccel == 'vaapi': - self.ffmpeg_args.extend([ - {'-init_hw_device': f'vaapi=va:{config.ffmpeg_hwaccel_device}'}, - {'-hwaccel': 'vaapi'}, - {'-hwaccel_output_format': 'vaapi'}, - ]) + self.ffmpeg_args.extend( + [ + {'-init_hw_device': f'vaapi=va:{config.ffmpeg_hwaccel_device}'}, + {'-hwaccel': 'vaapi'}, + {'-hwaccel_output_format': 'vaapi'}, + ] + ) def set_video(self): codec = codecs_to_library.get(self.video_output_codec, self.video_output_codec) @@ -227,27 +277,34 @@ def set_video(self): if self.settings.start_time > 0: i = self.find_ffmpeg_arg_index('-ss') # Audio goes out of sync if not used - self.ffmpeg_args.insert(i+1, {'-noaccurate_seek': None}) + self.ffmpeg_args.insert(i + 1, {'-noaccurate_seek': None}) - self.ffmpeg_args.extend([ - {'-start_at_zero': None}, - {'-avoid_negative_ts': 'disabled'}, - {'-copyts': None}, - ]) + self.ffmpeg_args.extend( + [ + {'-start_at_zero': None}, + {'-avoid_negative_ts': 'disabled'}, + {'-copyts': None}, + ] + ) else: if config.ffmpeg_hwaccel_enabled: codec = f'{self.settings.transcode_video_codec}_{config.ffmpeg_hwaccel}' self.video_output_codec_lib = codec - self.ffmpeg_args.extend([ - {'-map': '0:v:0'}, - {'-c:v': codec}, - ]) + self.ffmpeg_args.extend( + [ + {'-map': '0:v:0'}, + {'-c:v': codec}, + ] + ) if self.video_output_codec == 'hevc': - if self.can_copy_video and \ - self.video_color.range_type == 'dovi' and \ - self.video_stream.get('codec_tag_string') in ('dovi', 'dvh1', 'dvhe'): + if ( + self.can_copy_video + and self.video_color.range_type == 'dovi' + and self.video_stream.get('codec_tag_string') + in ('dovi', 'dvh1', 'dvhe') + ): self.ffmpeg_args.append({'-tag:v': 'dvh1'}) self.ffmpeg_args.append({'-strict': '2'}) else: @@ -266,7 +323,7 @@ def set_video(self): self.ffmpeg_args.append({'-low_power': '1'}) if self.settings.transcode_video_codec == 'hevc': # Fails with "Error while filtering: Cannot allocate memory" if not added - self.ffmpeg_args.append({'-async_depth': '1'}) + self.ffmpeg_args.append({'-async_depth': '1'}) vf = self.get_video_filter(width) if vf: @@ -275,26 +332,49 @@ def set_video(self): def get_can_copy_video(self, check_key_frames=True): if self.video_input_codec not in self.settings.supported_video_codecs: - logger.debug(f'[{self.settings.session}] Input codec not supported: {self.video_input_codec}') + logger.debug( + f'[{self.settings.session}] Input codec not supported: {self.video_input_codec}' + ) return False - - if self.video_color_bit_depth > self.settings.supported_video_color_bit_depth: - logger.debug(f'[{self.settings.session}] Video color bit depth not supported: {self.video_color_bit_depth}') + + if ( + self.settings.supported_video_color_bit_depth + and self.video_color_bit_depth + > int(self.settings.supported_video_color_bit_depth) + ): + logger.debug( + f'[{self.settings.session}] Video color bit depth not supported: {self.video_color_bit_depth}' + ) return False - - if self.video_color.range == 'hdr' and self.video_color.range_type not in self.settings.supported_hdr_formats and config.ffmpeg_tonemap_enabled: - logger.debug(f'[{self.settings.session}] HDR format not supported: {self.video_color.range_type}') + + if ( + self.video_color.range == 'hdr' + and self.video_color.range_type not in self.settings.supported_hdr_formats + and config.ffmpeg_tonemap_enabled + ): + logger.debug( + f'[{self.settings.session}] HDR format not supported: {self.video_color.range_type}' + ) return False - if self.settings.max_width and self.settings.max_width < self.video_stream['width']: - logger.debug(f'[{self.settings.session}] Requested width is lower than input width ({self.settings.max_width} < {self.video_stream["width"]})') + if ( + self.settings.max_width + and self.settings.max_width < self.video_stream['width'] + ): + logger.debug( + f'[{self.settings.session}] Requested width is lower than input width ({self.settings.max_width} < {self.video_stream["width"]})' + ) return False - - if self.settings.max_video_bitrate and self.settings.max_video_bitrate < int(self.metadata['format']['bit_rate'] or 0): - logger.debug(f'[{self.settings.session}] Requested max bitrate is lower than input bitrate ({self.settings.max_video_bitrate} < {self.get_video_transcode_bitrate()})') + + if self.settings.max_video_bitrate and self.settings.max_video_bitrate < int( + self.metadata['format']['bit_rate'] or 0 + ): + logger.debug( + f'[{self.settings.session}] Requested max bitrate is lower than input bitrate ({self.settings.max_video_bitrate} < {self.get_video_transcode_bitrate()})' + ) return False - - # We need the key frames to determin the actually start time when seeking + + # We need the key frames to determin the actually start time when seeking # otherwise the subtitles will be out of sync if check_key_frames and not self.metadata.get('keyframes'): logger.debug(f'[{self.settings.session}] No key frames in metadata') @@ -302,38 +382,58 @@ def get_can_copy_video(self, check_key_frames=True): logger.debug(f'[{self.settings.session}] Can copy video') return True - + def get_can_device_direct_play(self): if not self.get_can_copy_video(check_key_frames=False): return False - - if not any(fmt in self.settings.supported_video_containers \ - for fmt in self.metadata['format']['format_name'].split(',')): - logger.debug(f'[{self.settings.session}] Input video container not supported: {self.metadata["format"]["format_name"]}') + + if not any( + fmt in self.settings.supported_video_containers + for fmt in self.metadata['format']['format_name'].split(',') + ): + logger.debug( + f'[{self.settings.session}] Input video container not supported: {self.metadata["format"]["format_name"]}' + ) return False if not self.settings.client_can_switch_audio_track: if not self.audio_stream.get('disposition', {}).get('default'): if self.audio_stream['group_index'] != 0: - logger.debug(f'[{self.settings.session}] Client can\'t switch audio track') + logger.debug( + f"[{self.settings.session}] Client can't switch audio track" + ) return False logger.debug(f'[{self.settings.session}] Can direct play video') return True - + def get_video_filter(self, width: int): - vf = [] + vf = [] if self.video_color_bit_depth <= self.settings.supported_video_color_bit_depth: pix_fmt = self.video_stream['pix_fmt'] else: - pix_fmt = 'yuv420p' if self.settings.supported_video_color_bit_depth == 8 else 'yuv420p10le' - - tonemap = self.video_color.range_type not in self.settings.supported_hdr_formats and self.can_tonemap() + pix_fmt = ( + 'yuv420p' + if self.settings.supported_video_color_bit_depth == 8 + else 'yuv420p10le' + ) - if tonemap or (self.video_color.range == 'hdr' and self.video_color.range_type in self.settings.supported_hdr_formats): - vf.append('setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc') - else: - vf.append('setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709') + tonemap = ( + self.video_color.range_type not in self.settings.supported_hdr_formats + and self.can_tonemap() + ) + + if tonemap or ( + self.video_color.range == 'hdr' + and self.video_color.range_type in self.settings.supported_hdr_formats + ): + vf.append( + 'setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc' + ) + else: + vf.append( + 'setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709' + ) if not config.ffmpeg_hwaccel_enabled: if width: @@ -345,25 +445,27 @@ def get_video_filter(self, width: int): if pix_fmt == 'yuv420p10le': if self.video_output_codec_lib == 'h264_qsv': pix_fmt = 'yuv420p' - + format_ = '' if not tonemap: if pix_fmt == 'yuv420p10le': format_ = 'format=p010le' - else: + else: format_ = 'format=nv12' - + width_filter = f'w={width}:h=-2' if width != self.video_stream['width'] else '' if width_filter and format_: format_ = ':' + format_ - if width_filter or pix_fmt != self.video_stream['pix_fmt']: + if width_filter or pix_fmt != self.video_stream['pix_fmt']: if config.ffmpeg_hwaccel == 'qsv': vf.append(f'scale_vaapi={width_filter}{format_}') - else: + else: vf.append(f'scale_{config.ffmpeg_hwaccel}={width_filter}{format_}') if not tonemap: - vf.append(f'hwmap=derive_device={config.ffmpeg_hwaccel},format={config.ffmpeg_hwaccel}') + vf.append( + f'hwmap=derive_device={config.ffmpeg_hwaccel},format={config.ffmpeg_hwaccel}' + ) if tonemap: vf.extend(self.get_tonemap_hardware_filter()) @@ -392,28 +494,40 @@ def get_tonemap_hardware_filter(self): def can_tonemap(self): if self.video_color_bit_depth != 10 or not config.ffmpeg_tonemap_enabled: return False - - if self.video_input_codec == 'hevc' and self.video_color.range == 'hdr' and self.video_color.range_type == 'dovi': + + if ( + self.video_input_codec == 'hevc' + and self.video_color.range == 'hdr' + and self.video_color.range_type == 'dovi' + ): return config.ffmpeg_hwaccel in ('qsv', 'vaapi') - - return self.video_color.range == 'hdr' and (self.video_color.range_type in ('hdr10', 'hlg')) + + return self.video_color.range == 'hdr' and ( + self.video_color.range_type in ('hdr10', 'hlg') + ) def get_quality_params(self, width: int, codec_library: str): params = [] params.append({'-preset': config.ffmpeg_preset}) if codec_library == 'libx264': - params.append({'-x264opts': 'subme=0:me_range=4:rc_lookahead=10:me=hex:8x8dct=0:partitions=none'}) + params.append( + { + '-x264opts': 'subme=0:me_range=4:rc_lookahead=10:me=hex:8x8dct=0:partitions=none' + } + ) if width >= 3840: params.append({'-crf': 18}) elif width >= 1920: params.append({'-crf': 19}) else: params.append({'-crf': 26}) - + elif codec_library == 'libx265': - params.extend([ - {'-x265-params:0': 'no-info=1'}, - ]) + params.extend( + [ + {'-x265-params:0': 'no-info=1'}, + ] + ) if width >= 3840: params.append({'-crf': 18}) elif width >= 3840: @@ -434,7 +548,7 @@ def get_quality_params(self, width: int, codec_library: str): else: params.append({'-crf': 34}) - elif codec_library == 'h264_qsv': + elif codec_library == 'h264_qsv': params.append({'-look_ahead': '0'}) params.extend(self.get_video_bitrate_params(codec_library)) @@ -443,7 +557,7 @@ def get_quality_params(self, width: int, codec_library: str): def set_audio(self): stream = self.audio_stream codec = codecs_to_library.get(stream['codec_name'], '') - + # Audio goes out of sync if audio copy is used while the video is being transcoded if self.can_copy_video and self.can_copy_audio: codec = 'copy' @@ -451,7 +565,10 @@ def set_audio(self): if not codec or codec not in self.settings.supported_audio_codecs: codec = codecs_to_library.get(self.settings.transcode_audio_codec, '') bitrate = stream.get('bit_rate', stream['channels'] * 128000) - if self.settings.max_audio_channels and self.settings.max_audio_channels < stream['channels']: + if ( + self.settings.max_audio_channels + and self.settings.max_audio_channels < stream['channels'] + ): bitrate = self.settings.max_audio_channels * 128000 self.ffmpeg_args.append({'-ac': self.settings.max_audio_channels}) else: @@ -460,23 +577,34 @@ def set_audio(self): if not codec: raise Exception('No audio codec library') self.audio_output_codec_lib = codec - self.ffmpeg_args.extend([ - {'-map': f'0:{stream["index"]}'}, - {'-c:a': codec}, - ]) + self.ffmpeg_args.extend( + [ + {'-map': f'0:{stream["index"]}'}, + {'-c:a': codec}, + ] + ) def get_can_copy_audio(self): stream = self.audio_stream - if self.settings.max_audio_channels and self.settings.max_audio_channels < stream['channels']: - logger.debug(f'[{self.settings.session}] Requested audio channels is lower than input channels ({self.settings.max_audio_channels} < {stream["channels"]})') + if ( + self.settings.max_audio_channels + and self.settings.max_audio_channels < stream['channels'] + ): + logger.debug( + f'[{self.settings.session}] Requested audio channels is lower than input channels ({self.settings.max_audio_channels} < {stream["channels"]})' + ) return False - + if stream['codec_name'] not in self.settings.supported_audio_codecs: - logger.debug(f'[{self.settings.session}] Input audio codec not supported: {stream["codec_name"]}') + logger.debug( + f'[{self.settings.session}] Input audio codec not supported: {stream["codec_name"]}' + ) return False - logger.debug(f'[{self.settings.session}] Can copy audio, codec: {stream["codec_name"]}') + logger.debug( + f'[{self.settings.session}] Can copy audio, codec: {stream["codec_name"]}' + ) return True def get_video_bitrate_params(self, codec_library: str): @@ -485,13 +613,13 @@ def get_video_bitrate_params(self, codec_library: str): if codec_library in ('libx264', 'libx265', 'libvpx-vp9'): return [ {'-maxrate': bitrate}, - {'-bufsize': bitrate*2}, + {'-bufsize': bitrate * 2}, ] return [ {'-b:v': bitrate}, {'-maxrate': bitrate}, - {'-bufsize': bitrate*2}, + {'-bufsize': bitrate * 2}, ] def get_video_bitrate(self): @@ -501,16 +629,25 @@ def get_video_bitrate(self): return self.get_video_transcode_bitrate() def get_video_transcode_bitrate(self): - bitrate = self.settings.max_video_bitrate or int(self.metadata['format']['bit_rate'] or 0) + bitrate = self.settings.max_video_bitrate or int( + self.metadata['format']['bit_rate'] or 0 + ) if bitrate: - upscaling = self.settings.max_width and self.settings.max_width > self.video_stream['width'] + upscaling = ( + self.settings.max_width + and self.settings.max_width > self.video_stream['width'] + ) # only allow bitrate increase if upscaling if not upscaling: - bitrate = self._min_video_bitrate(int(self.metadata['format']['bit_rate']), bitrate) + bitrate = self._min_video_bitrate( + int(self.metadata['format']['bit_rate']), bitrate + ) + + bitrate = self._video_scale_bitrate( + bitrate, self.video_input_codec, self.settings.transcode_video_codec + ) - bitrate = self._video_scale_bitrate(bitrate, self.video_input_codec, self.settings.transcode_video_codec) - # don't exceed the requested bitrate if self.settings.max_video_bitrate: bitrate = min(bitrate, self.settings.max_video_bitrate) @@ -528,9 +665,9 @@ def _min_video_bitrate(self, input_bitrate: int, requested_bitrate: int): def _video_bitrate_scale_factor(self, codec: str): if codec in ('hevc', 'vp9'): - return .6 + return 0.6 if codec == 'av1': - return .5 + return 0.5 return 1 def _video_scale_bitrate(self, bitrate: int, input_codec: str, output_codec: str): @@ -552,7 +689,7 @@ def stream_index_by_lang(self, codec_type: str, lang: str): def get_video_stream(self): return get_video_stream(self.metadata) - + def get_audio_stream(self): index = self.stream_index_by_lang('audio', self.settings.audio_lang) self.metadata['streams'][index.index]['group_index'] = index.group_index @@ -586,7 +723,9 @@ def segment_time(self): def subprocess_env(session, type_): env = {} - env['FFREPORT'] = f'file=\'{os.path.join(config.transcode_folder, f"ffmpeg_{session}_{type_}.log")}\':level={config.ffmpeg_loglevel}' + env['FFREPORT'] = ( + f'file=\'{os.path.join(config.transcode_folder, f"ffmpeg_{session}_{type_}.log")}\':level={config.ffmpeg_loglevel}' + ) return env @@ -598,31 +737,37 @@ def to_subprocess_arguments(args): if value: l.append(str(value)) return l - + def get_video_stream(metadata: dict): for stream in metadata['streams']: if stream['codec_type'] == 'video': return stream - if not stream: - raise Exception('No video stream') - + raise Exception('No video stream') + def get_video_color(source: dict): - if source.get('color_transfer') == 'smpte2084' and source.get('color_primaries') == 'bt2020': + if ( + source.get('color_transfer') == 'smpte2084' + and source.get('color_primaries') == 'bt2020' + ): return Video_color(range='hdr', range_type='hdr10') - + if source.get('color_transfer') == 'arib-std-b67': return Video_color(range='hdr', range_type='hlg') - + if source.get('codec_tag_string') in ('dovi', 'dvh1', 'dvhe', 'dav1'): return Video_color(range='hdr', range_type='dovi') - + if source.get('side_data_list'): for s in source['side_data_list']: - if s.get('dv_profile') in (5, 7, 8) and \ - s.get('rpu_present_flag') and s.get('bl_present_flag') and s.get('dv_bl_signal_compatibility_id') in (0, 1, 4): - return Video_color(range='hdr', range_type='dovi') + if ( + s.get('dv_profile') in (5, 7, 8) + and s.get('rpu_present_flag') + and s.get('bl_present_flag') + and s.get('dv_bl_signal_compatibility_id') in (0, 1, 4) + ): + return Video_color(range='hdr', range_type='dovi') return Video_color(range='sdr', range_type='sdr') @@ -693,9 +838,11 @@ def close_session(session): if os.path.exists(s.transcode_folder): shutil.rmtree(s.transcode_folder) else: - logger.warning(f'[{session}] Path: {s.transcode_folder} not found, can\'t delete it') + logger.warning( + f"[{session}] Path: {s.transcode_folder} not found, can't delete it" + ) except: - pass + pass s.call_later.cancel() del sessions[session] @@ -704,4 +851,4 @@ def close_transcoder(session): try: sessions[session].process.kill() except: - pass \ No newline at end of file + pass diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b63b154 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +import pytest_asyncio +import tempfile +from seplis_play_server import config +from seplis_play_server.logger import set_logger + + +@pytest_asyncio.fixture(scope="function") +async def play_db_test(): + from seplis_play_server.database import database + from seplis_play_server import scan + + set_logger("play_test") + config.test = True + config.server_id = "123" + with tempfile.TemporaryDirectory() as dir: + import logging + logging.error(f"Using temp dir: {dir}") + config.database = f"sqlite:///{dir}/db.sqlite" + scan.upgrade_scan_db() + logging.error(f"Using database: {config.database}") + database.setup() + logging.error("Database setup") + yield database + logging.error("Closing database") + await database.close() diff --git a/tests/seplis_play_server/scanners/test_episodes.py b/tests/seplis_play_server/scanners/test_episodes.py index 0c74e1c..5e48f65 100644 --- a/tests/seplis_play_server/scanners/test_episodes.py +++ b/tests/seplis_play_server/scanners/test_episodes.py @@ -4,7 +4,7 @@ import sqlalchemy as sa from unittest import mock from datetime import date, datetime -from seplis_play_server.testbase import run_file, play_db_test +from seplis_play_server.testbase import run_file from seplis_play_server.database import Database from seplis_play_server import models, schemas @@ -12,129 +12,159 @@ @pytest.mark.asyncio async def test_get_files(): from seplis_play_server.scanners import Episode_scan - scanner = Episode_scan(scan_path='/', cleanup_mode=True, make_thumbnails=False) - with mock.patch('os.walk') as mockwalk: - mockwalk.return_value = [ - ('/series', ('NCIS', 'Person of Interest'), ()), - ('/series/NCIS', ('Season 01', 'Season 02'), ()), - ('/series/NCIS/Season 01', (), ( - 'NCIS.S01E01.Yankee White.avi', - 'NCIS.S01E02.Hung Out to Dry.avi', - )), - ('/series/NCIS/Season 02', (), ( - 'NCIS.S02E01.See No Evil.avi', - 'NCIS.S02E02.The Good Wives Club.avi', - )), - ('/series/Person of Interest', ('Season 01'), ()), - ('/series/Person of Interest/Season 01', (), ( - 'Person of Interest.S01E01.Pilot.mp4', - '._Person of Interest.S01E01.Pilot.mp4', - )), + + scanner = Episode_scan(scan_path="/", cleanup_mode=True, make_thumbnails=False) + with mock.patch("os.walk") as mockwalk: + mockwalk.return_value = [ + ("/series", ("NCIS", "Person of Interest"), ()), + ("/series/NCIS", ("Season 01", "Season 02"), ()), + ( + "/series/NCIS/Season 01", + (), + ( + "NCIS.S01E01.Yankee White.avi", + "NCIS.S01E02.Hung Out to Dry.avi", + ), + ), + ( + "/series/NCIS/Season 02", + (), + ( + "NCIS.S02E01.See No Evil.avi", + "NCIS.S02E02.The Good Wives Club.avi", + ), + ), + ("/series/Person of Interest", ("Season 01"), ()), + ( + "/series/Person of Interest/Season 01", + (), + ( + "Person of Interest.S01E01.Pilot.mp4", + "._Person of Interest.S01E01.Pilot.mp4", + ), + ), ] files = scanner.get_files() assert files == [ - '/series/NCIS/Season 01/NCIS.S01E01.Yankee White.avi', - '/series/NCIS/Season 01/NCIS.S01E02.Hung Out to Dry.avi', - '/series/NCIS/Season 02/NCIS.S02E01.See No Evil.avi', - '/series/NCIS/Season 02/NCIS.S02E02.The Good Wives Club.avi', - '/series/Person of Interest/Season 01/Person of Interest.S01E01.Pilot.mp4', + "/series/NCIS/Season 01/NCIS.S01E01.Yankee White.avi", + "/series/NCIS/Season 01/NCIS.S01E02.Hung Out to Dry.avi", + "/series/NCIS/Season 02/NCIS.S02E01.See No Evil.avi", + "/series/NCIS/Season 02/NCIS.S02E02.The Good Wives Club.avi", + "/series/Person of Interest/Season 01/Person of Interest.S01E01.Pilot.mp4", ] -@pytest.mark.asyncio -async def test_get_files(): - from seplis_play_server.scanners import Episode_scan - scanner = Episode_scan(scan_path='/', cleanup_mode=True, make_thumbnails=False) - - with mock.patch('subprocess.Popen') as mock_popen: - with mock.patch('os.path.exists') as mock_path_exists: - mock_path_exists.return_value = True - mock_popen().stdout.read.return_value = '{"metadata": "test"}' - mock_popen().stderr.read.return_value = None - - m = await scanner.get_metadata('somefile.mp4') - assert m == {'metadata': 'test'} - - @pytest.mark.asyncio @respx.mock async def test_series_id_lookup(play_db_test: Database): from seplis_play_server.scanners import Episode_scan - scanner = Episode_scan(scan_path='/', cleanup_mode=True, make_thumbnails=False) - respx.get('/2/search').mock(return_value=httpx.Response(200, json=[{ - 'id': 1, - 'title': 'Test series', - }])) + scanner = Episode_scan(scan_path="/", cleanup_mode=True, make_thumbnails=False) + + respx.get("/2/search").mock( + return_value=httpx.Response( + 200, + json=[ + { + "id": 1, + "title": "Test series", + } + ], + ) + ) # test that a series we haven't searched for is not in the db - assert None == await scanner.series_id.db_lookup('test series') + assert not await scanner.series_id.db_lookup("test series") # search for the series - assert 1 == await scanner.series_id.lookup('test series') + assert 1 == await scanner.series_id.lookup("test series") # the result should now be stored in the database - assert 1 == await scanner.series_id.db_lookup('test series') + assert 1 == await scanner.series_id.db_lookup("test series") @pytest.mark.asyncio async def test_save_item(play_db_test: Database): from seplis_play_server.scanners import Episode_scan - from seplis_play_server.scanners.episodes import Episode_scan from seplis_play_server.schemas import Parsed_file_episode - scanner = Episode_scan(scan_path='/', cleanup_mode=True, make_thumbnails=False) - scanner.get_file_modified_time = mock.MagicMock(return_value=datetime(2014, 11, 14, 21, 25, 58)) - scanner.get_metadata = mock.AsyncMock(return_value={ - 'some': 'data', - }) + + scanner = Episode_scan(scan_path="/", cleanup_mode=True, make_thumbnails=False) + scanner.get_file_modified_time = mock.MagicMock( + return_value=datetime(2014, 11, 14, 21, 25, 58) + ) + scanner.get_metadata = mock.AsyncMock( + return_value={ + "some": "data", + } + ) episodes = [] - episodes.append((Parsed_file_episode( - series_id=1, - title='ncis', - season=1, - episode=2, - episode_number=2, - ), '/ncis/ncis.s01e02.mp4')) - episodes.append((Parsed_file_episode( - series_id=1, - title='ncis', - air_date='2014-11-14', - episode_number=3, - ), '/ncis/ncis.2014-11-14.mp4')) - episodes.append((Parsed_file_episode( - series_id=1, - title='ncis', - episode_number=4, - ), '/ncis/ncis.4.mp4')) + episodes.append( + ( + Parsed_file_episode( + series_id=1, + title="ncis", + season=1, + episode=2, + episode_number=2, + ), + "/ncis/ncis.s01e02.mp4", + ) + ) + episodes.append( + ( + Parsed_file_episode( + series_id=1, + title="ncis", + date=date(2014, 11, 14), + episode_number=3, + ), + "/ncis/ncis.2014-11-14.mp4", + ) + ) + episodes.append( + ( + Parsed_file_episode( + series_id=1, + title="ncis", + episode_number=4, + ), + "/ncis/ncis.4.mp4", + ) + ) # episodes saved - for episode in episodes: - await scanner.save_item(episode[0], episode[1]) + with mock.patch("os.path.exists") as mock_get_files: + mock_get_files.return_value = True + for episode in episodes: + await scanner.save_item(episode[0], episode[1]) # check that metadata was called for all the episodes. - # if metadata i getting called the episode will be + # if metadata i getting called the episode will be # inserted/updated in the db. - scanner.get_metadata.assert_has_calls([ - mock.call('/ncis/ncis.s01e02.mp4'), - mock.call('/ncis/ncis.2014-11-14.mp4'), - mock.call('/ncis/ncis.4.mp4'), - ]) + scanner.get_metadata.assert_has_calls( + [ + mock.call("/ncis/ncis.s01e02.mp4"), + mock.call("/ncis/ncis.2014-11-14.mp4"), + mock.call("/ncis/ncis.4.mp4"), + ] + ) # check that calling `save_items` again does not result # in a update since the `modified_time` has not changed for - # any of them. + # any of them. scanner.get_metadata.reset_mock() for episode in episodes: await scanner.save_item(episode[0], episode[1]) scanner.get_metadata.assert_has_calls([]) # check that changing the `modified_time` will result in the - # episode getting updated in the db. + # episode getting updated in the db. scanner.get_metadata.reset_mock() scanner.get_file_modified_time.return_value = datetime(2014, 11, 15, 21, 25, 58) - await scanner.save_item(episodes[1][0], episodes[1][1]) + with mock.patch("os.path.exists") as mock_get_files: + await scanner.save_item(episodes[1][0], episodes[1][1]) scanner.get_metadata.assert_has_calls( - [mock.call('/ncis/ncis.2014-11-14.mp4')], + [mock.call("/ncis/ncis.2014-11-14.mp4")], ) async with play_db_test.session() as session: @@ -154,48 +184,50 @@ async def test_save_item(play_db_test: Database): @respx.mock async def test_episode_number_lookup(play_db_test: Database): from seplis_play_server.scanners import Episode_scan - from seplis_play_server.scanners.episodes import Episode_scan - scanner = Episode_scan(scan_path='/', cleanup_mode=True, make_thumbnails=False) + + scanner = Episode_scan(scan_path="/", cleanup_mode=True, make_thumbnails=False) # test parsed episode season - respx.get('/2/series/1/episodes', params={'season': '1', 'episode': '2'}).mock( - return_value=httpx.Response(200, + respx.get("/2/series/1/episodes", params={"season": "1", "episode": "2"}).mock( + return_value=httpx.Response( + 200, json=schemas.Page_cursor_result[schemas.Episode]( items=[schemas.Episode(number=2)] - ).dict()) + ).model_dump(), + ) ) episode = schemas.Parsed_file_episode( series_id=1, - title='NCIS', + title="NCIS", season=1, episode=2, ) - assert None == await scanner.episode_number.db_lookup(episode) + assert not await scanner.episode_number.db_lookup(episode) assert 2 == await scanner.episode_number.lookup(episode) assert 2 == await scanner.episode_number.db_lookup(episode) - # test parsed episode air_date - respx.get('/2/series/1/episodes', params={'air_date': '2014-11-14'}).mock( - return_value=httpx.Response(200, + respx.get("/2/series/1/episodes", params={"air_date": "2014-11-14"}).mock( + return_value=httpx.Response( + 200, json=schemas.Page_cursor_result[schemas.Episode]( items=[schemas.Episode(number=3)] - ).dict()) + ).model_dump(), + ) ) episode = schemas.Parsed_file_episode( series_id=1, - title='NCIS', + title="NCIS", date=date(2014, 11, 14), ) - assert None == await scanner.episode_number.db_lookup(episode) + assert not await scanner.episode_number.db_lookup(episode) assert 3 == await scanner.episode_number.lookup(episode) assert 3 == await scanner.episode_number.db_lookup(episode) - # test parsed episode number episode = schemas.Parsed_file_episode( series_id=1, - title='NCIS', + title="NCIS", episode_number=4, ) assert await scanner.episode_number_lookup(episode) @@ -205,80 +237,90 @@ async def test_episode_number_lookup(play_db_test: Database): @pytest.mark.asyncio async def test_parse_episodes(play_db_test: Database): from seplis_play_server.scanners import Episode_scan - from seplis_play_server.scanners.episodes import Episode_scan - scanner = Episode_scan(scan_path='/', cleanup_mode=True, make_thumbnails=False) + + scanner = Episode_scan(scan_path="/", cleanup_mode=True, make_thumbnails=False) # Normal - path = '/Alpha House/Alpha.House.S02E01.The.Love.Doctor.720p.AI.WEBRip.DD5.1.x264-NTb.mkv' + path = "/Alpha House/Alpha.House.S02E01.The.Love.Doctor.720p.AI.WEBRip.DD5.1.x264-NTb.mkv" info = scanner.parse(path) - assert info.title == 'alpha.house' + assert info + assert info.title == "alpha.house" assert info.season == 2 assert info.episode == 1 # Anime - path = '/Naruto/[HorribleSubs] Naruto Shippuuden - 379 [1080p].mkv' + path = "/Naruto/[HorribleSubs] Naruto Shippuuden - 379 [1080p].mkv" info = scanner.parse(path) - assert info.title == 'naruto shippuuden' + assert info + assert info.title == "naruto shippuuden" assert info.episode_number == 379 - path = '/Naruto Shippuuden/Naruto Shippuuden.426.720p.mkv' + path = "/Naruto Shippuuden/Naruto Shippuuden.426.720p.mkv" info = scanner.parse(path) - assert info.title, 'naruto shippuuden' + assert info + assert info.title, "naruto shippuuden" assert info.episode_number == 426 # Air date - path = '/The Daily series/The.Daily.series.2014.06.03.Ricky.Gervais.HDTV.x264-D0NK.mp4' + path = ( + "/The Daily series/The.Daily.series.2014.06.03.Ricky.Gervais.HDTV.x264-D0NK.mp4" + ) info = scanner.parse(path) - assert info.title, 'the.daily.series' - assert info.date.strftime('%Y-%m-%d') == '2014-06-03' + assert info + assert info.title, "the.daily.series" + assert info.date + assert info.date.strftime("%Y-%m-%d") == "2014-06-03" # Double episode - path = 'Star Wars Resistance.S01E01-E02.720p webdl h264 aac.mkv' + path = "Star Wars Resistance.S01E01-E02.720p webdl h264 aac.mkv" info = scanner.parse(path) - assert info.title == 'star wars resistance' + assert info + assert info.title == "star wars resistance" assert info.season == 1 assert info.episode == 1 - - path = 'Boruto Naruto Next Generations (2017) - 6.1080p h265.mkv' + + path = "Boruto Naruto Next Generations (2017) - 6.1080p h265.mkv" info = scanner.parse(path) - assert info.title == 'boruto naruto next generations (2017)' + assert info + assert info.title == "boruto naruto next generations (2017)" assert info.episode_number == 6 - - path = 'Boruto Naruto Next Generations (2017).mkv' + path = "Boruto Naruto Next Generations (2017).mkv" info = scanner.parse(path) - assert info == None + assert not info - - path = 'Vinland Saga (2019) - S01E01 - 005 - [HDTV-1080p][8bit][h264][AAC 2.0].mkv' + path = "Vinland Saga (2019) - S01E01 - 005 - [HDTV-1080p][8bit][h264][AAC 2.0].mkv" info = scanner.parse(path) + assert info assert info.season == 1 assert info.episode == 1 assert info.episode_number == 5 - assert info.title == 'vinland saga (2019)' - - path = 'The Big Bang Theory (2007) - S04E01 [Bluray-1080p][AAC 5.1][x265].mkv' + assert info.title == "vinland saga (2019)" + + path = "The Big Bang Theory (2007) - S04E01 [Bluray-1080p][AAC 5.1][x265].mkv" info = scanner.parse(path) + assert info assert info.season == 4 assert info.episode == 1 - assert info.episode_number == None - assert info.title == 'the big bang theory (2007)' + assert not info.episode_number + assert info.title == "the big bang theory (2007)" - path = 'Vinland Saga (2019) - S01E01 - 005 - [HDTV-1080p][8bit][h264][AAC 2.0].mkv' - scanner.parser = 'guessit' + path = "Vinland Saga (2019) - S01E01 - 005 - [HDTV-1080p][8bit][h264][AAC 2.0].mkv" + scanner.parser = "guessit" info = scanner.parse(path) + assert info assert info.season == 1 assert info.episode == 1 - assert info.title == 'vinland saga (2019)' + assert info.title == "vinland saga (2019)" - - path = 'the last of us - S01E02.mkv' - scanner.parser = 'guessit' + path = "the last of us - S01E02.mkv" + scanner.parser = "guessit" info = scanner.parse(path) + assert info assert info.season == 1 assert info.episode == 2 - assert info.title == 'the last of us' + assert info.title == "the last of us" -if __name__ == '__main__': - run_file(__file__) \ No newline at end of file +if __name__ == "__main__": + run_file(__file__) diff --git a/tests/seplis_play_server/scanners/test_movies.py b/tests/seplis_play_server/scanners/test_movies.py index 9a744fb..6f12258 100644 --- a/tests/seplis_play_server/scanners/test_movies.py +++ b/tests/seplis_play_server/scanners/test_movies.py @@ -1,34 +1,52 @@ -import pytest +from datetime import datetime +from unittest import mock + import httpx +import pytest import respx import sqlalchemy as sa -from unittest import mock -from datetime import datetime -from seplis_play_server.testbase import run_file, play_db_test + +from seplis_play_server import models from seplis_play_server.database import Database -from seplis_play_server import models, logger +from seplis_play_server.testbase import run_file @pytest.mark.asyncio @respx.mock async def test_movies(play_db_test: Database): from seplis_play_server.scanners import Movie_scan + scanner = Movie_scan(scan_path='/', cleanup_mode=True, make_thumbnails=False) - scanner.get_files = mock.MagicMock(return_value=[ - 'Uncharted.mkv', - ]) - scanner.get_metadata = mock.AsyncMock(return_value={ - 'some': 'data', - }) - scanner.get_file_modified_time = mock.MagicMock(return_value=datetime(2014, 11, 14, 21, 25, 58)) - - search = respx.get('/2/search').mock(return_value=httpx.Response(200, json=[{ - 'title': 'Uncharted', - 'id': 1, - }])) - - await scanner.scan() + scanner.get_files = mock.MagicMock( + return_value=[ + 'Uncharted.mkv', + ] + ) + scanner.get_metadata = mock.AsyncMock( + return_value={ + 'some': 'data', + } + ) + scanner.get_file_modified_time = mock.MagicMock( + return_value=datetime(2014, 11, 14, 21, 25, 58) + ) + + search = respx.get('/2/search').mock( + return_value=httpx.Response( + 200, + json=[ + { + 'title': 'Uncharted', + 'id': 1, + } + ], + ) + ) + + with mock.patch('os.path.exists') as mock_get_files: + mock_get_files.return_value = True + await scanner.scan() assert search.called async with play_db_test.session() as session: @@ -42,17 +60,32 @@ async def test_movies(play_db_test: Database): await scanner.delete_path('Uncharted.mkv') async with play_db_test.session() as session: r = await session.scalar(sa.select(models.Movie)) - assert r == None + assert not r @pytest.mark.asyncio async def test_movie_parse(): from seplis_play_server.scanners import Movie_scan + scanner = Movie_scan('/') - assert scanner.parse('Uncharted (2160p BluRay x265 10bit HDR Tigole).mkv') == 'Uncharted' - assert scanner.parse('Parasite.2019.REPACK.2160p.4K.BluRay.x265.10bit.AAC7.1-[YTS.MX].mkv') == 'Parasite (2019)' + assert ( + scanner.parse('Uncharted (2160p BluRay x265 10bit HDR Tigole).mkv') + == 'Uncharted' + ) + assert ( + scanner.parse( + 'Parasite.2019.REPACK.2160p.4K.BluRay.x265.10bit.AAC7.1-[YTS.MX].mkv' + ) + == 'Parasite (2019)' + ) assert scanner.parse('F9 (2021) [Bluray-1080p][AAC 5.1][x264].mkv') == 'F9 (2021)' + assert ( + scanner.parse( + 'Justice League Crisis on Infinite Earths Part Two (2024) [Bluray-1080p Proper][EAC3 5.1][x265]-GalaxyRG265.mkv' + ) + == 'Justice League Crisis on Infinite Earths Part Two (2024)' + ) if __name__ == '__main__': - run_file(__file__) \ No newline at end of file + run_file(__file__)