Merge pull request #12 from maxdorninger/rework-indexer-module

Rework indexer module
This commit is contained in:
Maximilian Dorninger
2025-06-08 18:48:05 +02:00
committed by GitHub
24 changed files with 631 additions and 163 deletions

View File

@@ -17,7 +17,7 @@
</p>
</div>
Media Manager aims to be a replacement for Sonarr/Radarr/Prowlarr/Overseer,
Media Manager aims to be a replacement for Sonarr/Radarr/Overseer/Jellyseer,
it is a comprehensive solution for organizing your media library, including TV shows and movies.
It provides a modern web interface and integrates with various services for metadata,
torrents and authentication.
@@ -27,21 +27,21 @@ torrents and authentication.
<!-- ROADMAP -->
## Roadmap
- [ ] support for movies
- [x] support for more torrent indexers
- [x] fully automatic downloads
- [x] add tests
- [x] add more logs/errors
- [x] make API return proper error codes
- [ ] support for movies
- [ ] responsive ui
- [ ] add check at startup if hardlinks work
- [ ] support multiple OIDC servers at once
- [x] make API return proper error codes
- [ ] support styling the login with OIDC button
- [ ] add in-depth documentation on the architecure of the codebase
- [ ] expand README with more information and a quickstart guide
- [ ] make indexer module multithreaded
- [ ] add notification system
- [ ] _maybe_ rework the logo
- [ ] add tests
- [ ] optimize images for web in the backend and merging frontend with backend container
- [x] add more logs/errors
- [ ] add support for deluge and transmission
- [ ] automatically download new seasons/episodes of shows

View File

@@ -0,0 +1,24 @@
from typing import Annotated
from fastapi import Depends
from media_manager.indexer.repository import IndexerRepository
from media_manager.indexer.service import IndexerService
from media_manager.database import DbSessionDependency
from media_manager.tv.service import TvService
def get_indexer_repository(db_session: DbSessionDependency) -> IndexerRepository:
return IndexerRepository(db_session)
indexer_repository_dep = Annotated[IndexerRepository, Depends(get_indexer_repository)]
def get_indexer_service(
indexer_repository: indexer_repository_dep,
) -> IndexerService:
return IndexerService(indexer_repository)
indexer_service_dep = Annotated[TvService, Depends(get_indexer_service)]

View File

@@ -1,3 +1,6 @@
from media_manager.indexer.schemas import IndexerQueryResult
class GenericIndexer(object):
name: str
@@ -7,7 +10,7 @@ class GenericIndexer(object):
else:
raise ValueError("indexer name must not be None")
def search(self, query: str) -> list["IndexerQueryResult"]:
def search(self, query: str) -> list[IndexerQueryResult]:
"""
Sends a search request to the Indexer and returns the results.

View File

@@ -6,7 +6,6 @@ from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql.sqltypes import BigInteger
from media_manager.database import Base
from media_manager.indexer.schemas import IndexerQueryResultId
from media_manager.torrent.schemas import Quality

View File

@@ -10,18 +10,18 @@ from media_manager.indexer.schemas import (
log = logging.getLogger(__name__)
def get_result(
result_id: IndexerQueryResultId, db: Session
) -> IndexerQueryResultSchema:
return IndexerQueryResultSchema.model_validate(
db.get(IndexerQueryResult, result_id)
)
class IndexerRepository:
def __init__(self, db: Session):
self.db = db
def save_result(
result: IndexerQueryResultSchema, db: Session
) -> IndexerQueryResultSchema:
log.debug("Saving indexer query result: %s", result)
db.add(IndexerQueryResult(**result.model_dump()))
db.commit()
return result
def get_result(self, result_id: IndexerQueryResultId) -> IndexerQueryResultSchema:
return IndexerQueryResultSchema.model_validate(
self.db.get(IndexerQueryResult, result_id)
)
def save_result(self, result: IndexerQueryResultSchema) -> IndexerQueryResultSchema:
log.debug("Saving indexer query result: %s", result)
self.db.add(IndexerQueryResult(**result.model_dump()))
self.db.commit()
return result

View File

@@ -56,13 +56,13 @@ class IndexerQueryResult(BaseModel):
def __gt__(self, other) -> bool:
if self.quality.value != other.quality.value:
return self.quality.value > other.quality.value
return self.seeders < other.seeders
return self.quality.value < other.quality.value
return self.seeders > other.seeders
def __lt__(self, other) -> bool:
if self.quality.value != other.quality.value:
return self.quality.value < other.quality.value
return self.seeders > other.seeders
return self.quality.value > other.quality.value
return self.seeders < other.seeders
class PublicIndexerQueryResult(BaseModel):

View File

@@ -1,25 +1,31 @@
from sqlalchemy.orm import Session
import media_manager.indexer.repository
from media_manager.indexer import log, indexers
from media_manager.indexer.repository import save_result
from media_manager.indexer.schemas import IndexerQueryResultId, IndexerQueryResult
from media_manager.indexer.repository import IndexerRepository
def search(query: str, db: Session) -> list[IndexerQueryResult]:
results = []
class IndexerService:
def __init__(self, indexer_repository: IndexerRepository):
self.repository = indexer_repository
log.debug(f"Searching for Torrent: {query}")
def get_result(self, result_id: IndexerQueryResultId) -> IndexerQueryResult:
return self.repository.get_result(result_id=result_id)
for i in indexers:
results.extend(i.search(query))
for result in results:
save_result(result=result, db=db)
log.debug(f"Found Torrents: {results}")
return results
def search(self, query: str) -> list[IndexerQueryResult]:
"""
Search for results using the indexers based on a query.
:param query: The search query.
:param db: The database session.
:return: A list of search results.
"""
log.debug(f"Searching for: {query}")
results = []
def get_indexer_query_result(
result_id: IndexerQueryResultId, db: Session
) -> IndexerQueryResult:
return media_manager.indexer.repository.get_result(result_id=result_id, db=db)
for indexer in indexers:
results.extend(indexer.search(query))
for result in results:
self.repository.save_result(result=result)
log.debug(f"Found torrents: {results}")
return results

View File

@@ -48,7 +48,10 @@ log = logging.getLogger(__name__)
from media_manager.database import init_db
import media_manager.tv.router as tv_router
from media_manager.tv.service import auto_download_all_approved_season_requests
from media_manager.tv.service import (
auto_download_all_approved_season_requests,
import_all_torrents,
)
import media_manager.torrent.router as torrent_router
from media_manager.config import BasicConfig
from fastapi import FastAPI
@@ -57,8 +60,6 @@ from datetime import datetime
from contextlib import asynccontextmanager
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
import media_manager.torrent.service
from media_manager.database import SessionLocal
init_db()
log.info("Database initialized")
@@ -77,10 +78,7 @@ else:
def hourly_tasks():
log.info(f"Tasks are running at {datetime.now()}")
auto_download_all_approved_season_requests()
# media_manager.torrent.service.TorrentService(
# db=SessionLocal()
#).import_all_torrents()
import_all_torrents()
scheduler = BackgroundScheduler()
trigger = CronTrigger(second=0, hour="*")

View File

@@ -1,12 +1,14 @@
import logging
from cachetools import TTLCache, cached
import media_manager.metadataProvider.tmdb
import media_manager.metadataProvider.tvdb
import media_manager.metadataProvider.tmdb as tmdb
import media_manager.metadataProvider.tvdb as tvdb
from media_manager.metadataProvider.abstractMetaDataProvider import metadata_providers
from media_manager.metadataProvider.schemas import MetaDataProviderShowSearchResult
from media_manager.tv.schemas import Show
_ = tvdb, tmdb
log = logging.getLogger(__name__)
search_show_cache = TTLCache(maxsize=128, ttl=24 * 60 * 60) # Cache for 24 hours

View File

@@ -2,18 +2,41 @@ from typing import Annotated
from fastapi import Depends
from exceptions import NotFoundError
from media_manager.database import DbSessionDependency
from media_manager.torrent.service import TorrentService
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import TorrentId, Torrent
from fastapi.exceptions import HTTPException
def get_torrent_repository(db: DbSessionDependency) -> TorrentRepository:
return TorrentRepository(db=db)
TorrentRepositoryDependency = Annotated[TorrentRepository, Depends(get_torrent_repository)]
def get_torrent_service(torrent_repository: TorrentRepositoryDependency) -> TorrentService:
torrent_repository_dep = Annotated[TorrentRepository, Depends(get_torrent_repository)]
def get_torrent_service(torrent_repository: torrent_repository_dep) -> TorrentService:
return TorrentService(torrent_repository=torrent_repository)
TorrentServiceDependency = Annotated[TorrentService, Depends(get_torrent_service)]
torrent_service_dep = Annotated[TorrentService, Depends(get_torrent_service)]
def get_torrent_by_id(
torrent_service: torrent_service_dep,
torrent_id: TorrentId
) -> Torrent:
"""
Retrieves a torrent by its ID.
:param torrent_service: The TorrentService instance.
:param torrent_id: The ID of the torrent to retrieve.
:return: The TorrentService instance with the specified torrent.
"""
try:
torrent = torrent_service.get_torrent_by_id(torrent_id=torrent_id)
except NotFoundError:
raise HTTPException(status_code=404, detail=f"Torrent with ID {torrent_id} not found")
return torrent
torrent_dep = Annotated[Torrent, Depends(get_torrent_by_id)]

View File

@@ -1,11 +1,11 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from media_manager.database import DbSessionDependency
from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import TorrentId, Torrent as TorrentSchema
from media_manager.tv.models import SeasonFile, Show, Season
from media_manager.tv.schemas import SeasonFile as SeasonFileSchema, Show as ShowSchema
from media_manager.exceptions import NotFoundError
class TorrentRepository:
@@ -19,7 +19,7 @@ class TorrentRepository:
result = self.db.execute(stmt).scalars().all()
return [SeasonFileSchema.model_validate(season_file) for season_file in result]
def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema:
def get_show_of_torrent(self, torrent_id: TorrentId) -> ShowSchema | None:
stmt = (
select(Show)
.join(SeasonFile.season)
@@ -27,6 +27,8 @@ class TorrentRepository:
.where(SeasonFile.torrent_id == torrent_id)
)
result = self.db.execute(stmt).unique().scalar_one_or_none()
if result is None:
return None
return ShowSchema.model_validate(result)
def save_torrent(self, torrent: TorrentSchema) -> TorrentSchema:
@@ -43,7 +45,10 @@ class TorrentRepository:
]
def get_torrent_by_id(self, torrent_id: TorrentId) -> TorrentSchema:
return TorrentSchema.model_validate(self.db.get(Torrent, torrent_id))
result = self.db.get(Torrent, torrent_id)
if result is None:
raise NotFoundError(f"Torrent with ID {torrent_id} not found.")
return TorrentSchema.model_validate(result)
def delete_torrent(self, torrent_id: TorrentId):
self.db.delete(self.db.get(Torrent, torrent_id))

View File

@@ -3,15 +3,15 @@ from fastapi import status
from fastapi.params import Depends
from media_manager.auth.users import current_active_user, current_superuser
from media_manager.torrent.dependencies import TorrentServiceDependency
from media_manager.torrent.dependencies import torrent_service_dep, torrent_dep
from media_manager.torrent.schemas import TorrentId, Torrent
router = APIRouter()
@router.get("/{torrent_id}", status_code=status.HTTP_200_OK, response_model=Torrent)
def get_torrent(service: TorrentServiceDependency, torrent_id: TorrentId):
return service.get_torrent_by_id(id=torrent_id)
def get_torrent(service: torrent_service_dep, torrent: torrent_dep):
return service.get_torrent_by_id(id=torrent.id)
@router.get(
@@ -20,7 +20,7 @@ def get_torrent(service: TorrentServiceDependency, torrent_id: TorrentId):
dependencies=[Depends(current_active_user)],
response_model=list[Torrent],
)
def get_all_torrents(service: TorrentServiceDependency):
def get_all_torrents(service: torrent_service_dep):
return service.get_all_torrents()
@@ -30,8 +30,8 @@ def get_all_torrents(service: TorrentServiceDependency):
dependencies=[Depends(current_active_user)],
response_model=Torrent,
)
def import_torrent(service: TorrentServiceDependency, torrent_id: TorrentId):
return service.import_torrent(service.get_torrent_by_id(id=torrent_id))
def import_torrent(service: torrent_service_dep, torrent: torrent_dep):
return service.import_torrent(service.get_torrent_by_id(id=torrent.id))
@router.post(
@@ -40,7 +40,7 @@ def import_torrent(service: TorrentServiceDependency, torrent_id: TorrentId):
dependencies=[Depends(current_active_user)],
response_model=list[Torrent],
)
def import_all_torrents(service: TorrentServiceDependency):
def import_all_torrents(service: torrent_service_dep):
return service.import_all_torrents()
@@ -49,5 +49,5 @@ def import_all_torrents(service: TorrentServiceDependency):
status_code=status.HTTP_200_OK,
dependencies=[Depends(current_superuser)],
)
def delete_torrent(service: TorrentServiceDependency, torrent_id: TorrentId):
service.delete_torrent(torrent_id=torrent_id)
def delete_torrent(service: torrent_service_dep, torrent: torrent_dep):
service.delete_torrent(torrent_id=torrent.id)

View File

@@ -1,27 +1,16 @@
import hashlib
import logging
import mimetypes
import pprint
import re
from pathlib import Path
import bencoder
import qbittorrentapi
import requests
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy.orm import Session
import media_manager.torrent.repository
from media_manager.config import BasicConfig
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.schemas import Torrent, TorrentStatus, TorrentId
from media_manager.torrent.utils import (
list_files_recursively,
get_torrent_filepath,
extract_archives,
)
from media_manager.tv.schemas import SeasonFile
from media_manager.tv.schemas import SeasonFile, Show
log = logging.getLogger(__name__)
@@ -75,7 +64,17 @@ class TorrentService:
:param torrent: the torrent to get the season files of
:return: list of season files
"""
return self.torrent_repository.get_seasons_files_of_torrent(torrent_id=torrent.id)
return self.torrent_repository.get_seasons_files_of_torrent(
torrent_id=torrent.id
)
def get_show_of_torrent(self, torrent: Torrent) -> Show|None:
"""
Returns the show of a torrent
:param torrent: the torrent to get the show of
:return: the show of the torrent
"""
return self.torrent_repository.get_show_of_torrent(torrent_id=torrent.id)
def download(self, indexer_result: IndexerQueryResult) -> Torrent:
log.info(f"Attempting to download torrent: {indexer_result.title}")
@@ -186,21 +185,12 @@ class TorrentService:
return self.get_torrent_status(
self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
)
# TODO: extract deletion logic to tv module
#def delete_torrent(self, torrent_id: TorrentId):
# def delete_torrent(self, torrent_id: TorrentId):
# t = self.torrent_repository.get_torrent_by_id(torrent_id=torrent_id)
# if not t.imported:
# from media_manager.tv.repository import remove_season_files_by_torrent_id
# remove_season_files_by_torrent_id(db=self.db, torrent_id=torrent_id)
# media_manager.torrent.repository.delete_torrent(db=self.db, torrent_id=t.id)
#def import_all_torrents(self) -> list[Torrent]:
# log.info("Importing all torrents")
# torrents = self.get_all_torrents()
# log.info("Found %d torrents to import", len(torrents))
# imported_torrents = []
# for t in torrents:
# if t.imported == False and t.status == TorrentStatus.finished:
# imported_torrents.append(self.import_torrent(t))
# log.info("Finished importing all torrents")
# return imported_torrents

View File

@@ -76,4 +76,4 @@ def import_torrent(torrent: Torrent) -> (list[Path], list[Path], list[Path]):
log.info(
f"Found {len(all_files)} files ({len(video_files)} video files, {len(subtitle_files)} subtitle files) for further processing."
)
return video_files, subtitle_files, all_files
return video_files, subtitle_files, all_files

View File

@@ -6,10 +6,10 @@ from media_manager.database import DbSessionDependency
from media_manager.tv.repository import TvRepository
from media_manager.tv.schemas import Show, ShowId, SeasonId, Season
from media_manager.tv.service import TvService
from media_manager.tv.exceptions import NotFoundError
from media_manager.exceptions import NotFoundError
from fastapi import HTTPException
from media_manager.torrent.dependencies import TorrentServiceDependency
from media_manager.indexer.dependencies import indexer_service_dep
from media_manager.torrent.dependencies import torrent_service_dep
def get_tv_repository(db_session: DbSessionDependency) -> TvRepository:
@@ -21,9 +21,14 @@ tv_repository_dep = Annotated[TvRepository, Depends(get_tv_repository)]
def get_tv_service(
tv_repository: tv_repository_dep,
torrent_service: TorrentServiceDependency
torrent_service: torrent_service_dep,
indexer_service: indexer_service_dep,
) -> TvService:
return TvService(tv_repository, torrent_service)
return TvService(
tv_repository=tv_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
)
tv_service_dep = Annotated[TvService, Depends(get_tv_service)]

View File

@@ -9,7 +9,7 @@ from media_manager.torrent.models import Torrent
from media_manager.torrent.schemas import TorrentId, Torrent as TorrentSchema
from media_manager.tv import log
from media_manager.tv.models import Season, Show, Episode, SeasonRequest, SeasonFile
from media_manager.tv.exceptions import NotFoundError
from media_manager.exceptions import NotFoundError
from media_manager.tv.schemas import (
Season as SeasonSchema,
SeasonId,
@@ -414,7 +414,7 @@ class TvRepository:
log.info(
f"Successfully removed {deleted_count} season files for torrent_id: {torrent_id}"
)
return deleted_count
return deleted_count()
except SQLAlchemyError as e:
self.db.rollback()
log.error(

View File

@@ -10,7 +10,7 @@ from media_manager.indexer.schemas import PublicIndexerQueryResult, IndexerQuery
from media_manager.metadataProvider.schemas import MetaDataProviderShowSearchResult
from media_manager.torrent.schemas import Torrent
from media_manager.tv import log
from media_manager.tv.exceptions import MediaAlreadyExists
from media_manager.exceptions import MediaAlreadyExists
from media_manager.tv.schemas import (
Show,
SeasonRequest,
@@ -24,7 +24,7 @@ from media_manager.tv.schemas import (
RichSeasonRequest,
)
from media_manager.tv.dependencies import (
tv_service_dep,
torrent_service_dep,
season_dep,
show_dep,
tv_repository_dep,
@@ -51,7 +51,7 @@ router = APIRouter()
},
)
def add_a_show(
tv_service: tv_service_dep, show_id: int, metadata_provider: str = "tmdb"
tv_service: torrent_service_dep, show_id: int, metadata_provider: str = "tmdb"
):
try:
show = tv_service.add_show(
@@ -83,7 +83,7 @@ def delete_a_show(tv_repository: tv_repository_dep, show_id: ShowId):
"/shows", dependencies=[Depends(current_active_user)], response_model=list[Show]
)
def get_all_shows(
tv_service: tv_service_dep, external_id: int = None, metadata_provider: str = "tmdb"
tv_service: torrent_service_dep, external_id: int = None, metadata_provider: str = "tmdb"
):
if external_id is not None:
return tv_service.get_show_by_external_id(
@@ -98,7 +98,7 @@ def get_all_shows(
dependencies=[Depends(current_active_user)],
response_model=list[RichShowTorrent],
)
def get_shows_with_torrents(tv_service: tv_service_dep):
def get_shows_with_torrents(tv_service: torrent_service_dep):
"""
get all shows that are associated with torrents
:return: A list of shows with all their torrents
@@ -112,7 +112,7 @@ def get_shows_with_torrents(tv_service: tv_service_dep):
dependencies=[Depends(current_active_user)],
response_model=PublicShow,
)
def get_a_show(show: show_dep, tv_service: tv_service_dep) -> PublicShow:
def get_a_show(show: show_dep, tv_service: torrent_service_dep) -> PublicShow:
return tv_service.get_public_show_by_id(show_id=show.id)
@@ -121,7 +121,7 @@ def get_a_show(show: show_dep, tv_service: tv_service_dep) -> PublicShow:
dependencies=[Depends(current_active_user)],
response_model=RichShowTorrent,
)
def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep):
def get_a_shows_torrents(show: show_dep, tv_service: torrent_service_dep):
return tv_service.get_torrents_for_show(show=show)
@@ -134,7 +134,7 @@ def get_a_shows_torrents(show: show_dep, tv_service: tv_service_dep):
def request_a_season(
user: Annotated[User, Depends(current_active_user)],
season_request: CreateSeasonRequest,
tv_service: tv_service_dep,
tv_service: torrent_service_dep,
):
"""
adds request flag to a season
@@ -156,7 +156,7 @@ def request_a_season(
dependencies=[Depends(current_active_user)],
response_model=list[RichSeasonRequest],
)
def get_season_requests(tv_service: tv_service_dep) -> list[RichSeasonRequest]:
def get_season_requests(tv_service: torrent_service_dep) -> list[RichSeasonRequest]:
return tv_service.get_all_season_requests()
@@ -165,7 +165,7 @@ def get_season_requests(tv_service: tv_service_dep) -> list[RichSeasonRequest]:
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_season_request(
tv_service: tv_service_dep,
tv_service: torrent_service_dep,
user: Annotated[User, Depends(current_active_user)],
request_id: SeasonRequestId,
):
@@ -188,7 +188,7 @@ def delete_season_request(
"/seasons/requests/{season_request_id}", status_code=status.HTTP_204_NO_CONTENT
)
def authorize_request(
tv_service: tv_service_dep,
tv_service: torrent_service_dep,
user: Annotated[User, Depends(current_superuser)],
season_request_id: SeasonRequestId,
authorized_status: bool = False,
@@ -209,7 +209,7 @@ def authorize_request(
@router.put("/seasons/requests", status_code=status.HTTP_204_NO_CONTENT)
def update_request(
tv_service: tv_service_dep,
tv_service: torrent_service_dep,
user: Annotated[User, Depends(current_active_user)],
season_request: UpdateSeasonRequest,
):
@@ -229,7 +229,7 @@ def update_request(
response_model=list[PublicSeasonFile],
)
def get_season_files(
season: season_dep, tv_service: tv_service_dep
season: season_dep, tv_service: torrent_service_dep
) -> list[PublicSeasonFile]:
return tv_service.get_public_season_files_by_season_id(season_id=season.id)
@@ -247,7 +247,7 @@ def get_season_files(
response_model=list[PublicIndexerQueryResult],
)
def get_torrents_for_a_season(
tv_service: tv_service_dep,
tv_service: torrent_service_dep,
show_id: ShowId,
season_number: int = 1,
search_query_override: str = None,
@@ -267,7 +267,7 @@ def get_torrents_for_a_season(
dependencies=[Depends(current_superuser)],
)
def download_a_torrent(
tv_service: tv_service_dep,
tv_service: torrent_service_dep,
public_indexer_result_id: IndexerQueryResultId,
show_id: ShowId,
override_file_path_suffix: str = "",
@@ -290,7 +290,7 @@ def download_a_torrent(
response_model=list[MetaDataProviderShowSearchResult],
)
def search_metadata_providers_for_a_show(
tv_service: tv_service_dep, query: str, metadata_provider: str = "tmdb"
tv_service: torrent_service_dep, query: str, metadata_provider: str = "tmdb"
):
return tv_service.search_for_show(query=query, metadata_provider=metadata_provider)
@@ -300,5 +300,5 @@ def search_metadata_providers_for_a_show(
dependencies=[Depends(current_active_user)],
response_model=list[MetaDataProviderShowSearchResult],
)
def get_recommended_shows(tv_service: tv_service_dep, metadata_provider: str = "tmdb"):
def get_recommended_shows(tv_service: torrent_service_dep, metadata_provider: str = "tmdb"):
return tv_service.get_popular_shows(metadata_provider=metadata_provider)

View File

@@ -6,11 +6,12 @@ from sqlalchemy.orm import Session
import media_manager.indexer.service
import media_manager.metadataProvider
import media_manager.torrent.repository
from media_manager.indexer.repository import IndexerRepository
from media_manager.database import SessionLocal
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.indexer.schemas import IndexerQueryResultId
from media_manager.metadataProvider.schemas import MetaDataProviderShowSearchResult
from media_manager.torrent.schemas import Torrent
from media_manager.torrent.schemas import Torrent, TorrentStatus
from media_manager.torrent.service import TorrentService
from media_manager.tv import log
from media_manager.tv.schemas import (
@@ -30,19 +31,25 @@ from media_manager.tv.schemas import (
)
from media_manager.torrent.schemas import QualityStrings
from media_manager.tv.repository import TvRepository
from media_manager.tv.exceptions import NotFoundError
import mimetypes
from media_manager.exceptions import NotFoundError
import pprint
from pathlib import Path
from media_manager.config import BasicConfig
from media_manager.torrent.repository import TorrentRepository
from media_manager.torrent.utils import import_file, import_torrent
from media_manager.indexer.service import IndexerService
class TvService:
def __init__(self, tv_repository: TvRepository, torrent_service: TorrentService):
def __init__(
self,
tv_repository: TvRepository,
torrent_service: TorrentService,
indexer_service: IndexerService,
):
self.tv_repository = tv_repository
self.torrent_service = torrent_service
self.indexer_service = indexer_service
def add_show(self, external_id: int, metadata_provider: str) -> Show | None:
"""
@@ -173,8 +180,8 @@ class TvService:
# TODO: add more Search query strings and combine all the results, like "season 3", "s03", "s3"
search_query = show.name + " s" + str(season_number).zfill(2)
torrents: list[IndexerQueryResult] = media_manager.indexer.service.search(
query=search_query, db=self.tv_repository.db
torrents: list[IndexerQueryResult] = self.indexer_service.search(
query=search_query
)
if search_query_override:
@@ -384,8 +391,8 @@ class TvService:
:param override_show_file_path_suffix: Optional override for the file path suffix.
:return: The downloaded torrent.
"""
indexer_result = media_manager.indexer.service.get_indexer_query_result(
db=self.tv_repository.db, result_id=public_indexer_result_id
indexer_result = self.indexer_service.get_result(
result_id=public_indexer_result_id
)
show_torrent = self.torrent_service.download(indexer_result=indexer_result)
@@ -568,7 +575,12 @@ def auto_download_all_approved_season_requests() -> None:
db: Session = SessionLocal()
tv_repository = TvRepository(db=db)
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
tv_service = TvService(tv_repository=tv_repository, torrent_service=torrent_service)
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
tv_service = TvService(
tv_repository=tv_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
)
log.info("Auto downloading all approved season requests")
season_requests = tv_repository.get_season_requests()
@@ -583,7 +595,8 @@ def auto_download_all_approved_season_requests() -> None:
season_id=season_request.season_id
)
if tv_service.download_approved_season_request(
season_request=season_request, show=show):
season_request=season_request, show=show
):
count += 1
else:
log.warning(
@@ -592,3 +605,29 @@ def auto_download_all_approved_season_requests() -> None:
log.info(f"Auto downloaded {count} approved season requests")
db.close()
def import_all_torrents() -> None:
db: Session = SessionLocal()
tv_repository = TvRepository(db=db)
torrent_service = TorrentService(torrent_repository=TorrentRepository(db=db))
indexer_service = IndexerService(indexer_repository=IndexerRepository(db=db))
tv_service = TvService(
tv_repository=tv_repository,
torrent_service=torrent_service,
indexer_service=indexer_service,
)
log.info("Importing all torrents")
torrents = torrent_service.get_all_torrents()
log.info("Found %d torrents to import", len(torrents))
imported_torrents = []
for t in torrents:
if t.imported == False and t.status == TorrentStatus.finished:
show = torrent_service.get_show_of_torrent(torrent=t)
if show is None:
log.warning(
f"torrent {t.title} is not a tv torrent, skipping import."
)
continue
imported_torrents.append(tv_service.import_torrent_files(torrent=t, show=show))
log.info("Finished importing all torrents")

View File

View File

@@ -0,0 +1,63 @@
import uuid
import pytest
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
from media_manager.indexer.repository import IndexerRepository
class DummyDB:
def __init__(self):
self._storage = {}
self.added = []
self.committed = False
def get(self, model, result_id):
return self._storage.get(result_id)
def add(self, obj):
self.added.append(obj)
self._storage[obj.id] = obj
def commit(self):
self.committed = True
@pytest.fixture
def dummy_db():
return DummyDB()
@pytest.fixture
def repo(dummy_db):
return IndexerRepository(db=dummy_db)
def test_save_and_get_result(repo, dummy_db):
result_id = IndexerQueryResultId(uuid.uuid4())
result = IndexerQueryResult(
id=result_id,
title="Test Title",
download_url="http://example.com",
seeders=5,
flags=["flag1"],
size=1234,
)
saved = repo.save_result(result)
assert saved == result
assert dummy_db.committed
fetched = repo.get_result(result_id)
assert fetched.id == result_id
assert fetched.title == "Test Title"
def test_save_result_calls_db_methods(repo, dummy_db):
result = IndexerQueryResult(
id=IndexerQueryResultId(uuid.uuid4()),
title="Another Title",
download_url="http://example.com/2",
seeders=2,
flags=[],
size=5678,
)
repo.save_result(result)
assert dummy_db.added[0].title == "Another Title"
assert dummy_db.committed

View File

@@ -0,0 +1,209 @@
from media_manager.indexer.schemas import IndexerQueryResult
from media_manager.torrent.models import Quality
def test_quality_computed_field():
assert (
IndexerQueryResult(
title="Show S01 4K", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.uhd
)
assert (
IndexerQueryResult(
title="Show S01 1080p", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.fullhd
)
assert (
IndexerQueryResult(
title="Show S01 720p", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.hd
)
assert (
IndexerQueryResult(
title="Show S01 480p", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.sd
)
assert (
IndexerQueryResult(
title="Show S01", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.unknown
)
def test_quality_computed_field_edge_cases():
# Case-insensitive
assert (
IndexerQueryResult(
title="Show S01 4k", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.uhd
)
assert (
IndexerQueryResult(
title="Show S01 1080P", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.fullhd
)
assert (
IndexerQueryResult(
title="Show S01 720P", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.hd
)
assert (
IndexerQueryResult(
title="Show S01 480P", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.sd
)
# Multiple quality tags, prefer highest
assert (
IndexerQueryResult(
title="Show S01 4K 1080p 720p", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.uhd
)
assert (
IndexerQueryResult(
title="Show S01 1080p 720p", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.fullhd
)
# No quality tag
assert (
IndexerQueryResult(
title="Show S01", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.unknown
)
# Quality tag in the middle
assert (
IndexerQueryResult(
title="4K Show S01", download_url="", seeders=1, flags=[], size=1
).quality
== Quality.uhd
)
def test_season_computed_field():
# Single season
assert IndexerQueryResult(
title="Show S01", download_url="", seeders=1, flags=[], size=1
).season == [1]
# Range of seasons
assert IndexerQueryResult(
title="Show S01 S03", download_url="", seeders=1, flags=[], size=1
).season == [1, 2, 3]
# No season
assert (
IndexerQueryResult(
title="Show", download_url="", seeders=1, flags=[], size=1
).season
== []
)
def test_season_computed_field_edge_cases():
# Multiple seasons, unordered
assert (
IndexerQueryResult(
title="Show S03 S01", download_url="", seeders=1, flags=[], size=1
).season
== []
)
# Season with leading zeros
assert IndexerQueryResult(
title="Show S01 S03", download_url="", seeders=1, flags=[], size=1
).season == [1, 2, 3]
assert IndexerQueryResult(
title="Show S01 S01", download_url="", seeders=1, flags=[], size=1
).season == [1]
# No season at all
assert (
IndexerQueryResult(
title="Show", download_url="", seeders=1, flags=[], size=1
).season
== []
)
# Season in lower/upper case
assert IndexerQueryResult(
title="Show s02", download_url="", seeders=1, flags=[], size=1
).season == [2]
assert IndexerQueryResult(
title="Show S02", download_url="", seeders=1, flags=[], size=1
).season == [2]
# Season with extra text
assert IndexerQueryResult(
title="Show S01 Complete", download_url="", seeders=1, flags=[], size=1
).season == [1]
def test_gt_and_lt_methods():
a = IndexerQueryResult(
title="Show S01 1080p", download_url="", seeders=5, flags=[], size=1
)
b = IndexerQueryResult(
title="Show S01 720p", download_url="", seeders=10, flags=[], size=1
)
c = IndexerQueryResult(
title="Show S01 1080p", download_url="", seeders=2, flags=[], size=1
)
# a (fullhd) > b (hd)
assert a > b
assert not (b > a)
# If quality is equal, compare by seeders (lower seeders is less than higher seeders)
assert c < a
assert a > c
# If quality is equal, but seeders are equal, neither is greater
d = IndexerQueryResult(
title="Show S01 1080p", download_url="", seeders=5, flags=[], size=1
)
assert not (a < d)
assert not (a > d)
def test_gt_and_lt_methods_edge_cases():
# Different qualities
a = IndexerQueryResult(
title="Show S01 4K", download_url="", seeders=1, flags=[], size=1
)
b = IndexerQueryResult(
title="Show S01 1080p", download_url="", seeders=100, flags=[], size=1
)
assert a > b
assert not (b > a)
# Same quality, different seeders
c = IndexerQueryResult(
title="Show S01 4K", download_url="", seeders=2, flags=[], size=1
)
assert a < c
assert c > a
# Same quality and seeders
d = IndexerQueryResult(
title="Show S01 4K", download_url="", seeders=1, flags=[], size=1
)
assert not (a < d)
assert not (a > d)
# Unknown quality, should compare by seeders
e = IndexerQueryResult(
title="Show S01", download_url="", seeders=5, flags=[], size=1
)
f = IndexerQueryResult(
title="Show S01", download_url="", seeders=10, flags=[], size=1
)
assert e < f
assert f > e
# Mixed known and unknown quality
g = IndexerQueryResult(
title="Show S01 720p", download_url="", seeders=1, flags=[], size=1
)
h = IndexerQueryResult(
title="Show S01", download_url="", seeders=100, flags=[], size=1
)
assert g > h
assert not (h > g)

View File

@@ -0,0 +1,60 @@
import uuid
from unittest.mock import MagicMock
import pytest
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
from media_manager.indexer.repository import IndexerRepository
from media_manager.indexer.service import IndexerService
class DummyIndexer:
def search(self, query):
return [
IndexerQueryResult(
id=IndexerQueryResultId(uuid.uuid4()),
title=f"{query} S01 1080p",
download_url="http://example.com/1",
seeders=10,
flags=["test"],
size=123456,
)
]
@pytest.fixture
def mock_indexer_repository():
repo = MagicMock(spec=IndexerRepository)
repo.save_result.side_effect = lambda result: result
return repo
@pytest.fixture
def indexer_service(monkeypatch, mock_indexer_repository):
# Patch the global indexers list
monkeypatch.setattr("media_manager.indexer.service.indexers", [DummyIndexer()])
return IndexerService(indexer_repository=mock_indexer_repository)
def test_search_returns_results(indexer_service, mock_indexer_repository):
query = "TestShow"
results = indexer_service.search(query)
assert len(results) == 1
assert results[0].title == f"{query} S01 1080p"
mock_indexer_repository.save_result.assert_called_once()
def test_get_result_returns_result(mock_indexer_repository):
result_id = IndexerQueryResultId(uuid.uuid4())
expected_result = IndexerQueryResult(
id=result_id,
title="Test S01 1080p",
download_url="http://example.com/1",
seeders=10,
flags=["test"],
size=123456,
)
mock_indexer_repository.get_result.return_value = expected_result
service = IndexerService(indexer_repository=mock_indexer_repository)
result = service.get_result(result_id)
assert result == expected_result
mock_indexer_repository.get_result.assert_called_once_with(result_id=result_id)

View File

@@ -3,12 +3,11 @@ from unittest.mock import MagicMock, patch
import pytest
from media_manager.tv.exceptions import NotFoundError
from media_manager.tv.schemas import Show, ShowId, SeasonId
from media_manager.exceptions import NotFoundError
from media_manager.tv.schemas import Show, ShowId
from media_manager.tv.service import TvService
from media_manager.indexer.schemas import IndexerQueryResult, IndexerQueryResultId
from media_manager.metadataProvider.schemas import MetaDataProviderShowSearchResult
from media_manager.torrent.models import Quality
@pytest.fixture
@@ -22,8 +21,17 @@ def mock_torrent_service():
@pytest.fixture
def tv_service(mock_tv_repository, mock_torrent_service):
return TvService(tv_repository=mock_tv_repository, torrent_service=mock_torrent_service)
def mock_indexer_service():
return MagicMock()
@pytest.fixture
def tv_service(mock_tv_repository, mock_torrent_service, mock_indexer_service):
return TvService(
tv_repository=mock_tv_repository,
torrent_service=mock_torrent_service,
indexer_service=mock_indexer_service,
)
def test_add_show(tv_service, mock_tv_repository, mock_torrent_service):
@@ -54,7 +62,9 @@ def test_add_show(tv_service, mock_tv_repository, mock_torrent_service):
assert result == show_data
def test_add_show_with_invalid_metadata(monkeypatch, tv_service, mock_tv_repository, mock_torrent_service):
def test_add_show_with_invalid_metadata(
monkeypatch, tv_service, mock_tv_repository, mock_torrent_service
):
external_id = 123
metadata_provider = "tmdb"
# Simulate metadata provider returning None
@@ -68,7 +78,9 @@ def test_add_show_with_invalid_metadata(monkeypatch, tv_service, mock_tv_reposit
assert result is None
def test_check_if_show_exists_by_external_id(tv_service, mock_tv_repository, mock_torrent_service):
def test_check_if_show_exists_by_external_id(
tv_service, mock_tv_repository, mock_torrent_service
):
external_id = 123
metadata_provider = "tmdb"
mock_tv_repository.get_show_by_external_id.return_value = "show_obj"
@@ -85,7 +97,9 @@ def test_check_if_show_exists_by_external_id(tv_service, mock_tv_repository, moc
)
def test_check_if_show_exists_by_show_id(tv_service, mock_tv_repository, mock_torrent_service):
def test_check_if_show_exists_by_show_id(
tv_service, mock_tv_repository, mock_torrent_service
):
show_id = ShowId(uuid.uuid4())
mock_tv_repository.get_show_by_id.return_value = "show_obj"
assert tv_service.check_if_show_exists(show_id=show_id)
@@ -95,7 +109,9 @@ def test_check_if_show_exists_by_show_id(tv_service, mock_tv_repository, mock_to
assert not tv_service.check_if_show_exists(show_id=show_id)
def test_check_if_show_exists_with_invalid_uuid(tv_service, mock_tv_repository, mock_torrent_service):
def test_check_if_show_exists_with_invalid_uuid(
tv_service, mock_tv_repository, mock_torrent_service
):
# Simulate NotFoundError for a random UUID
show_id = uuid.uuid4()
mock_tv_repository.get_show_by_id.side_effect = NotFoundError
@@ -189,7 +205,9 @@ def test_get_show_by_external_id(tv_service, mock_tv_repository, mock_torrent_se
assert result == show
def test_get_show_by_external_id_not_found(tv_service, mock_tv_repository, mock_torrent_service):
def test_get_show_by_external_id_not_found(
tv_service, mock_tv_repository, mock_torrent_service
):
external_id = 123
metadata_provider = "tmdb"
mock_tv_repository.get_show_by_external_id.side_effect = NotFoundError
@@ -267,14 +285,18 @@ def test_get_public_season_files_by_season_id_not_downloaded(
assert result[0].downloaded is False
def test_get_public_season_files_by_season_id_empty(tv_service, mock_tv_repository, mock_torrent_service):
def test_get_public_season_files_by_season_id_empty(
tv_service, mock_tv_repository, mock_torrent_service
):
season_id = uuid.uuid4()
mock_tv_repository.get_season_files_by_season_id.return_value = []
result = tv_service.get_public_season_files_by_season_id(season_id)
assert result == []
def test_is_season_downloaded_true(monkeypatch, tv_service, mock_tv_repository, mock_torrent_service):
def test_is_season_downloaded_true(
monkeypatch, tv_service, mock_tv_repository, mock_torrent_service
):
season_id = MagicMock()
season_file = MagicMock()
mock_tv_repository.get_season_files_by_season_id.return_value = [season_file]
@@ -284,7 +306,9 @@ def test_is_season_downloaded_true(monkeypatch, tv_service, mock_tv_repository,
assert tv_service.is_season_downloaded(season_id) is True
def test_is_season_downloaded_false(monkeypatch, tv_service, mock_tv_repository, mock_torrent_service):
def test_is_season_downloaded_false(
monkeypatch, tv_service, mock_tv_repository, mock_torrent_service
):
season_id = MagicMock()
season_file = MagicMock()
mock_tv_repository.get_season_files_by_season_id.return_value = [season_file]
@@ -294,7 +318,9 @@ def test_is_season_downloaded_false(monkeypatch, tv_service, mock_tv_repository,
assert tv_service.is_season_downloaded(season_id) is False
def test_is_season_downloaded_with_no_files(tv_service, mock_tv_repository, mock_torrent_service):
def test_is_season_downloaded_with_no_files(
tv_service, mock_tv_repository, mock_torrent_service
):
season_id = uuid.uuid4()
mock_tv_repository.get_season_files_by_season_id.return_value = []
assert tv_service.is_season_downloaded(season_id) is False
@@ -306,16 +332,22 @@ def test_season_file_exists_on_file_none(monkeypatch, tv_service, mock_torrent_s
assert tv_service.season_file_exists_on_file(season_file) is True
def test_season_file_exists_on_file_imported(monkeypatch, tv_service, mock_torrent_service):
def test_season_file_exists_on_file_imported(
monkeypatch, tv_service, mock_torrent_service
):
season_file = MagicMock()
season_file.torrent_id = "torrent_id"
torrent_file = MagicMock(imported=True)
# Patch the repository method on the torrent_service instance
tv_service.torrent_service.torrent_repository.get_torrent_by_id = MagicMock(return_value=torrent_file)
tv_service.torrent_service.torrent_repository.get_torrent_by_id = MagicMock(
return_value=torrent_file
)
assert tv_service.season_file_exists_on_file(season_file) is True
def test_season_file_exists_on_file_not_imported(monkeypatch, tv_service, mock_torrent_service):
def test_season_file_exists_on_file_not_imported(
monkeypatch, tv_service, mock_torrent_service
):
season_file = MagicMock()
season_file.torrent_id = "torrent_id"
torrent_file = MagicMock()
@@ -325,7 +357,9 @@ def test_season_file_exists_on_file_not_imported(monkeypatch, tv_service, mock_t
assert tv_service.season_file_exists_on_file(season_file) is False
def test_season_file_exists_on_file_with_none_imported(monkeypatch, tv_service, mock_torrent_service):
def test_season_file_exists_on_file_with_none_imported(
monkeypatch, tv_service, mock_torrent_service
):
class DummyFile:
def __init__(self):
self.torrent_id = uuid.uuid4()
@@ -335,11 +369,15 @@ def test_season_file_exists_on_file_with_none_imported(monkeypatch, tv_service,
class DummyTorrent:
imported = True
tv_service.torrent_service.torrent_repository.get_torrent_by_id = MagicMock(return_value=DummyTorrent())
tv_service.torrent_service.torrent_repository.get_torrent_by_id = MagicMock(
return_value=DummyTorrent()
)
assert tv_service.season_file_exists_on_file(dummy_file) is True
def test_season_file_exists_on_file_with_none_not_imported(monkeypatch, tv_service, mock_torrent_service):
def test_season_file_exists_on_file_with_none_not_imported(
monkeypatch, tv_service, mock_torrent_service
):
class DummyFile:
def __init__(self):
self.torrent_id = uuid.uuid4()
@@ -349,12 +387,14 @@ def test_season_file_exists_on_file_with_none_not_imported(monkeypatch, tv_servi
class DummyTorrent:
imported = False
tv_service.torrent_service.get_torrent_by_id = MagicMock(return_value=DummyTorrent())
tv_service.torrent_service.get_torrent_by_id = MagicMock(
return_value=DummyTorrent()
)
assert tv_service.season_file_exists_on_file(dummy_file) is False
def test_get_all_available_torrents_for_a_season_no_override(
tv_service, mock_tv_repository, mock_torrent_service, monkeypatch
tv_service, mock_tv_repository, mock_torrent_service, mock_indexer_service
):
show_id = ShowId(uuid.uuid4())
season_number = 1
@@ -411,18 +451,21 @@ def test_get_all_available_torrents_for_a_season_no_override(
size=100,
) # Different season
mock_search = MagicMock(
return_value=[torrent1, torrent2, torrent3, torrent4, torrent5]
)
monkeypatch.setattr("media_manager.indexer.service.search", mock_search)
mock_indexer_service.search.return_value = [
torrent1,
torrent2,
torrent3,
torrent4,
torrent5,
]
results = tv_service.get_all_available_torrents_for_a_season(
season_number=season_number, show_id=show_id
)
mock_tv_repository.get_show_by_id.assert_called_once_with(show_id=show_id)
mock_search.assert_called_once_with(
query=f"{show_name} s{str(season_number).zfill(2)}", db=mock_tv_repository.db
mock_indexer_service.search.assert_called_once_with(
query=f"{show_name} s{str(season_number).zfill(2)}"
)
assert len(results) == 3
assert torrent1 in results
@@ -436,7 +479,7 @@ def test_get_all_available_torrents_for_a_season_no_override(
def test_get_all_available_torrents_for_a_season_with_override(
tv_service, mock_tv_repository, mock_torrent_service, monkeypatch
tv_service, mock_tv_repository, mock_torrent_service, mock_indexer_service
):
show_id = ShowId(uuid.uuid4())
season_number = 1
@@ -461,8 +504,7 @@ def test_get_all_available_torrents_for_a_season_with_override(
size=100,
season=[1],
)
mock_search = MagicMock(return_value=[torrent1])
monkeypatch.setattr("media_manager.indexer.service.search", mock_search)
mock_indexer_service.search.return_value = [torrent1]
results = tv_service.get_all_available_torrents_for_a_season(
season_number=season_number,
@@ -470,12 +512,12 @@ def test_get_all_available_torrents_for_a_season_with_override(
search_query_override=override_query,
)
mock_search.assert_called_once_with(query=override_query, db=mock_tv_repository.db)
mock_indexer_service.search.assert_called_once_with(query=override_query)
assert results == [torrent1]
def test_get_all_available_torrents_for_a_season_no_results(
tv_service, mock_tv_repository, mock_torrent_service, monkeypatch
tv_service, mock_tv_repository, mock_torrent_service, mock_indexer_service
):
show_id = ShowId(uuid.uuid4())
season_number = 1
@@ -490,8 +532,7 @@ def test_get_all_available_torrents_for_a_season_no_results(
)
mock_tv_repository.get_show_by_id.return_value = mock_show
mock_search = MagicMock(return_value=[])
monkeypatch.setattr("media_manager.indexer.service.search", mock_search)
mock_indexer_service.search.return_value = []
results = tv_service.get_all_available_torrents_for_a_season(
season_number=season_number, show_id=show_id
@@ -625,11 +666,12 @@ def test_get_popular_shows_all_added(tv_service, mock_torrent_service, monkeypat
assert results == []
def test_get_popular_shows_empty_from_provider(tv_service, mock_torrent_service, monkeypatch):
def test_get_popular_shows_empty_from_provider(
tv_service, mock_torrent_service, monkeypatch
):
metadata_provider = "tmdb"
mock_search_show = MagicMock(return_value=[])
monkeypatch.setattr("media_manager.metadataProvider.search_show", mock_search_show)
results = tv_service.get_popular_shows(metadata_provider=metadata_provider)
assert results == []