Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from alembic import op
import sqlalchemy as sa

revision = 'b7d9f1a3c5e8'
down_revision = 'e1a3c5d7f9b2'
branch_labels = None
depends_on = None

def upgrade() -> None:
op.add_column('library_item', sa.Column('poster_generated_at', sa.DateTime(), nullable=True))

def downgrade() -> None:
with op.batch_alter_table('library_item') as batch_op:
batch_op.drop_column('poster_generated_at')
10 changes: 9 additions & 1 deletion affiche-backend/affiche/api/routers/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,18 @@ def get_library_item_counts(media_server_id: int,
providers = service.count_items_by_provider(query.to_domain(library_id, provider=None))
return LibraryItemCounts(
total=stats.total, unprocessed=stats.unprocessed,
errors=stats.errors, locked=stats.locked,
errors=stats.errors, locked=stats.locked, ready=stats.ready, uploaded=stats.uploaded,
providers={(provider or NO_PROVIDER): count for provider, count in providers.items()},
)

@router.get("/{library_id}/items/ids", response_model=List[int])
def get_library_item_ids(media_server_id: int,
library_id: int,
query: Annotated[LibraryItemQuery, Query()],
service: LibraryService = Depends(get_library_service)) -> List[int]:
service.get_library(media_server_id, library_id)
return service.find_item_ids(query.to_domain(library_id, page_size=None))

@router.get("/{library_id}/items/alpha-index", response_model=List[AlphaIndexEntry])
def get_library_alpha_index(media_server_id: int,
library_id: int,
Expand Down
3 changes: 3 additions & 0 deletions affiche-backend/affiche/api/schemas/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class LibraryItemResponse(BaseModel):
updated_at: Optional[datetime] = None
last_seen_at: Optional[datetime] = None
poster_uploaded_at: Optional[datetime] = None
poster_generated_at: Optional[datetime] = None
imdb_id: Optional[str] = None
tmdb_id: Optional[int] = None
tvdb_id: Optional[int] = None
Expand Down Expand Up @@ -199,6 +200,8 @@ class LibraryItemCounts(BaseModel):
unprocessed: int
errors: int
locked: int
ready: int
uploaded: int
providers: Dict[str, int] = {}

class LibraryStyleStaleness(BaseModel):
Expand Down
6 changes: 6 additions & 0 deletions affiche-backend/affiche/api/schemas/service_schemas.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

from pydantic import BaseModel

class ProviderTestRequest(BaseModel):
Expand All @@ -9,3 +11,7 @@ class PosterCandidate(BaseModel):
provider: str
rank: int = 0
rank_score: float = 1.0
language: Optional[str] = None
textless: Optional[bool] = None
width: Optional[int] = None
height: Optional[int] = None
4 changes: 2 additions & 2 deletions affiche-backend/affiche/app/auth/service/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ def create_admin(self, username: str, password: str) -> User:
username = (username or "").strip()
if not username:
raise AuthError("Username is required")
if not password:
raise AuthError("Password is required")
if len(password or "") < MIN_PASSWORD_LENGTH:
raise AuthError(f"Password must be at least {MIN_PASSWORD_LENGTH} characters")
if self.has_admin():
raise AuthError("An admin account already exists")
return self._repository.create(username, _hash_password(password))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def bucket_letter(title: Optional[str]) -> str:
'year': LibraryItemEntity.year,
'release_date': LibraryItemEntity.release_date,
'added_at': LibraryItemEntity.added_at,
'poster_generated_at': LibraryItemEntity.poster_generated_at,
'resolution': LibraryItemEntity.media_height,
'codec': LibraryItemEntity.video_codec,
'size': LibraryItemEntity.media_size_bytes,
Expand All @@ -46,6 +47,10 @@ def _pending_clause():
def _error_clause():
return LibraryItemEntity.error_message.is_not(None)

def _ready_clause():
return and_(LibraryItemEntity.processed.is_(True), LibraryItemEntity.error_message.is_(None),
LibraryItemEntity.poster_uploaded_at.is_(None))

def _attempted_clause():
return or_(LibraryItemEntity.processed.is_(True), _error_clause())

Expand All @@ -67,6 +72,7 @@ def _bucket_sums():
'errors': func.sum(case((_error_clause(), 1), else_=0)),
'locked': func.sum(case((LibraryItemEntity.locked.is_(True), 1), else_=0)),
'uploaded': func.sum(case((LibraryItemEntity.poster_uploaded_at.is_not(None), 1), else_=0)),
'ready': func.sum(case((_ready_clause(), 1), else_=0)),
}

def _stats_from_row(row) -> LibraryItemStats:
Expand Down Expand Up @@ -216,6 +222,10 @@ def find_items(self, search: LibraryItemSearch) -> List[LibraryItem]:
query = self._paged(self._ordered(self._search_query(search), search), search)
return [LibraryItem.model_validate(item) for item in query.all()]

def find_item_ids(self, search: LibraryItemSearch) -> List[int]:
query = self._ordered(self._search_query(search), search).with_entities(LibraryItemEntity.id)
return [row[0] for row in query.all()]

def count_items(self, search: LibraryItemSearch) -> int:
return self._search_query(search).count()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class LibraryItemEntity(Base):
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
last_seen_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
poster_uploaded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
poster_generated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)

imdb_id: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class LibraryItem(BaseModel):
updated_at: Optional[datetime] = None
last_seen_at: Optional[datetime] = None
poster_uploaded_at: Optional[datetime] = None
poster_generated_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None

imdb_id: Optional[str] = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ class ItemStatusFilter(str, Enum):
UNPROCESSED = "unprocessed"
ERRORS = "errors"
LOCKED = "locked"
READY = "ready"
UPLOADED = "uploaded"

NO_PROVIDER = "none"

Expand All @@ -34,12 +36,16 @@ class LibraryItemSearch(SearchCriteria):
@model_validator(mode='after')
def _expand_and_check(self) -> 'LibraryItemSearch':
if self.status is not None:
if self.processed is not None or self.has_error is not None or self.locked is not None:
raise ValueError("pass either `status` or `processed`/`has_error`/`locked`, not both")
if (self.processed is not None or self.has_error is not None
or self.locked is not None or self.uploaded is not None):
raise ValueError(
"pass either `status` or `processed`/`has_error`/`locked`/`uploaded`, not both")
expanded = {
ItemStatusFilter.UNPROCESSED: {'processed': False, 'has_error': False},
ItemStatusFilter.ERRORS: {'has_error': True},
ItemStatusFilter.LOCKED: {'locked': True},
ItemStatusFilter.READY: {'processed': True, 'has_error': False, 'uploaded': False},
ItemStatusFilter.UPLOADED: {'uploaded': True},
}[self.status]
for field, value in expanded.items():
object.__setattr__(self, field, value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class LibraryItemStats(BaseModel):
errors: int = 0
locked: int = 0
uploaded: int = 0
ready: int = 0

def __add__(self, other: 'LibraryItemStats') -> 'LibraryItemStats':
return LibraryItemStats(**{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ def create_or_update_items_batch(self, items: List[LibraryItem]) -> None:
def find_items(self, search: LibraryItemSearch) -> List[LibraryItem]:
return self._connector.find_items(search)

def find_item_ids(self, search: LibraryItemSearch) -> List[int]:
return self._connector.find_item_ids(search)

def count_items(self, search: LibraryItemSearch) -> int:
return self._connector.count_items(search)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ def find_libraries(self, search: LibrarySearch) -> List[Library]:
def find_items(self, search: LibraryItemSearch) -> List[LibraryItem]:
return self.library_repo.find_items(search)

def find_item_ids(self, search: LibraryItemSearch) -> List[int]:
return self.library_repo.find_item_ids(search)

def count_items(self, search: LibraryItemSearch) -> int:
return self.library_repo.count_items(search)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from datetime import datetime, timezone
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional, Callable, List, NamedTuple

Expand Down Expand Up @@ -452,6 +453,7 @@ def _process_item_poster(self,
uploaded = upload and self._uploader.upload_if_changed(item, stored.path, stored.digest, connector)

item.processed = True
item.poster_generated_at = datetime.now(timezone.utc)
item.poster_provider = provider
item.style_hash = self._decorator.style_fingerprint(overlay_options, text_options, apply_style)
if not uploaded:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def reset_poster(self,
item.poster_hash = None
item.poster_provider = None
item.style_hash = None
item.poster_generated_at = None
item.error_message = None
self._cache_source_poster(item, connector, poster_url=reset.poster_url)
repo.create_or_update_item(item)
Expand Down
16 changes: 12 additions & 4 deletions affiche-backend/affiche/external/poster/poster_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class ProviderPoster(NamedTuple):
provider: str
rank: int = 0
rank_score: float = 1.0
language: Optional[str] = None
textless: Optional[bool] = None
width: Optional[int] = None
height: Optional[int] = None

MIN_AGREEING_MEMBERS = 2

Expand Down Expand Up @@ -70,7 +74,7 @@ def lookup(p=provider):

result = self._ask(provider, lookup, None)
if result:
return ProviderPoster(result, provider.name)
return ProviderPoster(str(result), provider.name)

logger.warning(f"Could not find any poster for {title}")
return None
Expand All @@ -91,7 +95,7 @@ def find_best_season_poster(self,
language=language
), None)
if result:
return ProviderPoster(result, provider.name)
return ProviderPoster(str(result), provider.name)

logger.warning(f"No season poster found for {title} season {season_number}")
return None
Expand Down Expand Up @@ -201,8 +205,12 @@ def _tag(self, provider: ExternalProvider,
urls = self._ask(provider, call, []) or []
last = len(urls) - 1
return [
ProviderPoster(url, provider.name, rank=index,
rank_score=1.0 if last <= 0 else 1.0 - index / last)
ProviderPoster(str(url), provider.name, rank=index,
rank_score=1.0 if last <= 0 else 1.0 - index / last,
language=getattr(url, "language", None),
textless=getattr(url, "textless", None),
width=getattr(url, "width", None),
height=getattr(url, "height", None))
for index, url in enumerate(urls)
]

Expand Down
16 changes: 16 additions & 0 deletions affiche-backend/affiche/external/poster/provider/base_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ class BaseUrlMode(str, Enum):
FIXED = "fixed"
USER = "user"

class PosterImage(str):

language: Optional[str]
textless: Optional[bool]
width: Optional[int]
height: Optional[int]

def __new__(cls, url: str, *, language: Optional[str] = None, textless: Optional[bool] = None,
width: Optional[int] = None, height: Optional[int] = None):
image = super().__new__(cls, url)
image.language = language.lower() if language else None
image.textless = textless
image.width = width or None
image.height = height or None
return image

class ExternalProvider(ABC):

requires_api_key: bool = True
Expand Down
17 changes: 12 additions & 5 deletions affiche-backend/affiche/external/poster/provider/fanart.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import requests

from affiche.config.http_config import HTTP_TIMEOUT
from affiche.external.poster.provider.base_provider import ExternalProvider
from affiche.external.poster.provider.base_provider import ExternalProvider, PosterImage

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -93,14 +93,14 @@ def _fetch_movie_posters(self, tmdb_id: int) -> List[str]:

movie_posters = data.get("movieposter", [])
poster_urls.extend([
poster["url"]
self._image(poster)
for poster in movie_posters
if poster.get("url") and poster.get("lang") == '00'
])

movie_art = data.get("movieart", [])
poster_urls.extend([
art["url"]
self._image(art)
for art in movie_art
if art.get("url")
])
Expand All @@ -121,7 +121,7 @@ def _fetch_tv_posters(self, tvdb_id: int) -> List[str]:

tv_posters = data.get("tvposter", [])
poster_urls.extend([
poster["url"]
self._image(poster)
for poster in tv_posters
if poster.get("url")
])
Expand All @@ -131,6 +131,13 @@ def _fetch_tv_posters(self, tvdb_id: int) -> List[str]:
logger.error(f"Error fetching Fanart TV posters for {tvdb_id}: {e}")
return []

@staticmethod
def _image(entry: dict) -> PosterImage:
lang = entry.get("lang") or None
if lang == "00":
return PosterImage(entry["url"], textless=True)
return PosterImage(entry["url"], language=lang, textless=False if lang else None)

def _fetch_season_poster(self, tvdb_id: int, season_number: int) -> Optional[str]:
try:
endpoint = f"{self.base_url}/tv/{tvdb_id}"
Expand Down Expand Up @@ -160,7 +167,7 @@ def _fetch_all_season_poster(self, tvdb_id: int, season_number: int) -> List[str
posters = []
for poster in season_posters:
if poster.get("season") == str(season_number) and poster.get("url"):
posters.append(poster["url"])
posters.append(self._image(poster))

return posters
except requests.RequestException as e:
Expand Down
7 changes: 4 additions & 3 deletions affiche-backend/affiche/external/poster/provider/mediux.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import requests

from affiche.config.http_config import HTTP_TIMEOUT
from affiche.external.poster.provider.base_provider import ExternalProvider
from affiche.external.poster.provider.base_provider import ExternalProvider, PosterImage

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -293,13 +293,14 @@ def _matches_language(asset: dict, language: Optional[str]) -> bool:
return False
return ((asset.get("language") or {}).get("iso_639_1")) == language

def _asset_url(self, asset: dict) -> Optional[str]:
def _asset_url(self, asset: dict) -> Optional[PosterImage]:
asset_id = (asset or {}).get("id")
if not asset_id:
return None
url = f"{self.image_base}/{asset_id}"
version = self._format_modified(asset.get("modified_on"))
return f"{url}?v={version}" if version else url
return PosterImage(f"{url}?v={version}" if version else url,
language=(asset.get("language") or {}).get("iso_639_1"))

@staticmethod
def _format_modified(value: Optional[str]) -> Optional[str]:
Expand Down
5 changes: 3 additions & 2 deletions affiche-backend/affiche/external/poster/provider/shoko.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import requests

from affiche.config.http_config import HTTP_TIMEOUT
from affiche.external.poster.provider.base_provider import BaseUrlMode, ExternalProvider
from affiche.external.poster.provider.base_provider import BaseUrlMode, ExternalProvider, PosterImage

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -143,7 +143,8 @@ def _poster_urls(self, images, language: Optional[str]) -> List[str]:
width = image.get("Width") or 0
if width and width < self.MIN_POSTER_WIDTH:
continue
candidates.append((width, url))
candidates.append((width, PosterImage(url, language=image.get("LanguageCode"),
width=width, height=image.get("Height"))))

candidates.sort(key=lambda candidate: candidate[0], reverse=True)
return [url for _, url in candidates]
Expand Down
Loading
Loading