Add year support to anime folder names
- Add year property to Serie entity with name_with_year - Add year column to AnimeSeries database model - Add get_year() method to AniworldLoader provider - Extract year from folder names before fetching from API - Update SerieScanner to populate year during rescan - Update add_series endpoint to fetch and store year - Optimize: check folder name for year before API call
This commit is contained in:
parent
ccbd9768a2
commit
40ffb99c97
@ -117,6 +117,44 @@ class SerieScanner:
|
|||||||
if handler in self.events.on_progress:
|
if handler in self.events.on_progress:
|
||||||
self.events.on_progress.remove(handler)
|
self.events.on_progress.remove(handler)
|
||||||
|
|
||||||
|
def _extract_year_from_folder_name(self, folder_name: str) -> int | None:
|
||||||
|
"""Extract year from folder name if present.
|
||||||
|
|
||||||
|
Looks for year in format "(YYYY)" at the end of folder name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_name: The folder name to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int or None: Year if found, None otherwise
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> _extract_year_from_folder_name("Dororo (2025)")
|
||||||
|
2025
|
||||||
|
>>> _extract_year_from_folder_name("Dororo")
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
if not folder_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Look for year in format (YYYY) - typically at end of name
|
||||||
|
match = re.search(r'\((\d{4})\)', folder_name)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
year = int(match.group(1))
|
||||||
|
# Validate year is reasonable (between 1900 and 2100)
|
||||||
|
if 1900 <= year <= 2100:
|
||||||
|
logger.debug(
|
||||||
|
"Extracted year from folder name: %s -> %d",
|
||||||
|
folder_name,
|
||||||
|
year
|
||||||
|
)
|
||||||
|
return year
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def subscribe_on_error(self, handler):
|
def subscribe_on_error(self, handler):
|
||||||
"""
|
"""
|
||||||
Subscribe a handler to an event.
|
Subscribe a handler to an event.
|
||||||
@ -235,6 +273,33 @@ class SerieScanner:
|
|||||||
and serie.key
|
and serie.key
|
||||||
and serie.key.strip()
|
and serie.key.strip()
|
||||||
):
|
):
|
||||||
|
# Try to extract year from folder name first
|
||||||
|
if not hasattr(serie, 'year') or not serie.year:
|
||||||
|
year_from_folder = self._extract_year_from_folder_name(folder)
|
||||||
|
if year_from_folder:
|
||||||
|
serie.year = year_from_folder
|
||||||
|
logger.info(
|
||||||
|
"Using year from folder name: %s (year=%d)",
|
||||||
|
folder,
|
||||||
|
year_from_folder
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If not in folder name, fetch from provider
|
||||||
|
try:
|
||||||
|
serie.year = self.loader.get_year(serie.key)
|
||||||
|
if serie.year:
|
||||||
|
logger.info(
|
||||||
|
"Fetched year from provider: %s (year=%d)",
|
||||||
|
serie.key,
|
||||||
|
serie.year
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not fetch year for %s: %s",
|
||||||
|
serie.key,
|
||||||
|
str(e)
|
||||||
|
)
|
||||||
|
|
||||||
# Delegate the provider to compare local files with
|
# Delegate the provider to compare local files with
|
||||||
# remote metadata, yielding missing episodes per
|
# remote metadata, yielding missing episodes per
|
||||||
# season. Results are saved back to disk so that both
|
# season. Results are saved back to disk so that both
|
||||||
@ -611,19 +676,46 @@ class SerieScanner:
|
|||||||
sum(len(eps) for eps in missing_episodes.values())
|
sum(len(eps) for eps in missing_episodes.values())
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Try to extract year from folder name first
|
||||||
|
year = self._extract_year_from_folder_name(folder)
|
||||||
|
if year:
|
||||||
|
logger.info(
|
||||||
|
"Using year from folder name: %s (year=%d)",
|
||||||
|
folder,
|
||||||
|
year
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If not in folder name, fetch from provider
|
||||||
|
try:
|
||||||
|
year = self.loader.get_year(key)
|
||||||
|
if year:
|
||||||
|
logger.info(
|
||||||
|
"Fetched year from provider: %s (year=%d)",
|
||||||
|
key,
|
||||||
|
year
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not fetch year for %s: %s",
|
||||||
|
key,
|
||||||
|
str(e)
|
||||||
|
)
|
||||||
|
|
||||||
# Create new serie entry
|
# Create new serie entry
|
||||||
serie = Serie(
|
serie = Serie(
|
||||||
key=key,
|
key=key,
|
||||||
name="", # Will be populated by caller if needed
|
name="", # Will be populated by caller if needed
|
||||||
site=site,
|
site=site,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
episodeDict=missing_episodes
|
episodeDict=missing_episodes,
|
||||||
|
year=year
|
||||||
)
|
)
|
||||||
self.keyDict[key] = serie
|
self.keyDict[key] = serie
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Created new series entry for %s with %d missing episodes",
|
"Created new series entry for %s with %d missing episodes (year=%s)",
|
||||||
key,
|
key,
|
||||||
sum(len(eps) for eps in missing_episodes.values())
|
sum(len(eps) for eps in missing_episodes.values()),
|
||||||
|
year
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify completion
|
# Notify completion
|
||||||
|
|||||||
@ -22,6 +22,7 @@ class Serie:
|
|||||||
e.g., "Attack on Titan (2013)")
|
e.g., "Attack on Titan (2013)")
|
||||||
episodeDict: Dictionary mapping season numbers to
|
episodeDict: Dictionary mapping season numbers to
|
||||||
lists of episode numbers
|
lists of episode numbers
|
||||||
|
year: Release year of the series (optional)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If key is None or empty string
|
ValueError: If key is None or empty string
|
||||||
@ -33,7 +34,8 @@ class Serie:
|
|||||||
name: str,
|
name: str,
|
||||||
site: str,
|
site: str,
|
||||||
folder: str,
|
folder: str,
|
||||||
episodeDict: dict[int, list[int]]
|
episodeDict: dict[int, list[int]],
|
||||||
|
year: int | None = None
|
||||||
):
|
):
|
||||||
if not key or not key.strip():
|
if not key or not key.strip():
|
||||||
raise ValueError("Serie key cannot be None or empty")
|
raise ValueError("Serie key cannot be None or empty")
|
||||||
@ -43,13 +45,15 @@ class Serie:
|
|||||||
self._site = site
|
self._site = site
|
||||||
self._folder = folder
|
self._folder = folder
|
||||||
self._episodeDict = episodeDict
|
self._episodeDict = episodeDict
|
||||||
|
self._year = year
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""String representation of Serie object"""
|
"""String representation of Serie object"""
|
||||||
|
year_str = f", year={self.year}" if self.year else ""
|
||||||
return (
|
return (
|
||||||
f"Serie(key='{self.key}', name='{self.name}', "
|
f"Serie(key='{self.key}', name='{self.name}', "
|
||||||
f"site='{self.site}', folder='{self.folder}', "
|
f"site='{self.site}', folder='{self.folder}', "
|
||||||
f"episodeDict={self.episodeDict})"
|
f"episodeDict={self.episodeDict}{year_str})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -129,29 +133,65 @@ class Serie:
|
|||||||
def episodeDict(self, value: dict[int, list[int]]):
|
def episodeDict(self, value: dict[int, list[int]]):
|
||||||
self._episodeDict = value
|
self._episodeDict = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def year(self) -> int | None:
|
||||||
|
"""
|
||||||
|
Release year of the series.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int or None: The year the series was released, or None if unknown
|
||||||
|
"""
|
||||||
|
return self._year
|
||||||
|
|
||||||
|
@year.setter
|
||||||
|
def year(self, value: int | None):
|
||||||
|
"""Set the release year of the series."""
|
||||||
|
self._year = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_with_year(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the series name with year appended if available.
|
||||||
|
|
||||||
|
Returns a name in the format "Name (Year)" if year is available,
|
||||||
|
otherwise returns just the name. This should be used for creating
|
||||||
|
filesystem folders to distinguish series with the same name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Name with year in format "Name (Year)", or just name if no year
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> serie = Serie("dororo", "Dororo", ..., year=2025)
|
||||||
|
>>> serie.name_with_year
|
||||||
|
'Dororo (2025)'
|
||||||
|
"""
|
||||||
|
if self._year:
|
||||||
|
return f"{self._name} ({self._year})"
|
||||||
|
return self._name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sanitized_folder(self) -> str:
|
def sanitized_folder(self) -> str:
|
||||||
"""
|
"""
|
||||||
Get a filesystem-safe folder name derived from the display name.
|
Get a filesystem-safe folder name derived from the display name with year.
|
||||||
|
|
||||||
This property returns a sanitized version of the series name
|
This property returns a sanitized version of the series name with year
|
||||||
suitable for use as a filesystem folder name. It removes/replaces
|
(if available) suitable for use as a filesystem folder name. It removes/
|
||||||
characters that are invalid for filesystems while preserving
|
replaces characters that are invalid for filesystems while preserving
|
||||||
Unicode characters.
|
Unicode characters.
|
||||||
|
|
||||||
Use this property when creating folders for the series on disk.
|
Use this property when creating folders for the series on disk.
|
||||||
The `folder` property stores the actual folder name used.
|
The `folder` property stores the actual folder name used.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Filesystem-safe folder name based on display name
|
str: Filesystem-safe folder name based on display name with year
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ...)
|
>>> serie = Serie("attack-on-titan", "Attack on Titan: Final", ..., year=2025)
|
||||||
>>> serie.sanitized_folder
|
>>> serie.sanitized_folder
|
||||||
'Attack on Titan Final'
|
'Attack on Titan Final (2025)'
|
||||||
"""
|
"""
|
||||||
# Use name if available, fall back to folder, then key
|
# Use name_with_year if available, fall back to folder, then key
|
||||||
name_to_sanitize = self._name or self._folder or self._key
|
name_to_sanitize = self.name_with_year or self._folder or self._key
|
||||||
try:
|
try:
|
||||||
return sanitize_folder_name(name_to_sanitize)
|
return sanitize_folder_name(name_to_sanitize)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -167,7 +207,8 @@ class Serie:
|
|||||||
"folder": self.folder,
|
"folder": self.folder,
|
||||||
"episodeDict": {
|
"episodeDict": {
|
||||||
str(k): list(v) for k, v in self.episodeDict.items()
|
str(k): list(v) for k, v in self.episodeDict.items()
|
||||||
}
|
},
|
||||||
|
"year": self.year
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -182,7 +223,8 @@ class Serie:
|
|||||||
data["name"],
|
data["name"],
|
||||||
data["site"],
|
data["site"],
|
||||||
data["folder"],
|
data["folder"],
|
||||||
episode_dict
|
episode_dict,
|
||||||
|
data.get("year") # Optional year field for backward compatibility
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_to_file(self, filename: str):
|
def save_to_file(self, filename: str):
|
||||||
|
|||||||
@ -380,6 +380,54 @@ class AniworldLoader(Loader):
|
|||||||
logging.warning(f"No title found for key: {key}")
|
logging.warning(f"No title found for key: {key}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def get_year(self, key: str) -> int | None:
|
||||||
|
"""Get anime release year from series key.
|
||||||
|
|
||||||
|
Attempts to extract the year from the series page metadata.
|
||||||
|
Returns None if year cannot be determined.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int or None: Release year if found, None otherwise
|
||||||
|
"""
|
||||||
|
logging.debug(f"Getting year for key: {key}")
|
||||||
|
try:
|
||||||
|
soup = BeautifulSoup(
|
||||||
|
self._get_key_html(key).content,
|
||||||
|
'html.parser'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to find year in metadata
|
||||||
|
# Check for "Jahr:" or similar metadata fields
|
||||||
|
for p_tag in soup.find_all('p'):
|
||||||
|
text = p_tag.get_text()
|
||||||
|
if 'Jahr:' in text or 'Year:' in text:
|
||||||
|
# Extract year from text like "Jahr: 2025"
|
||||||
|
match = re.search(r'(\d{4})', text)
|
||||||
|
if match:
|
||||||
|
year = int(match.group(1))
|
||||||
|
logging.debug(f"Found year in metadata: {year}")
|
||||||
|
return year
|
||||||
|
|
||||||
|
# Try alternative: look for year in genre/info section
|
||||||
|
info_div = soup.find('div', class_='series-info')
|
||||||
|
if info_div:
|
||||||
|
text = info_div.get_text()
|
||||||
|
match = re.search(r'\b(19\d{2}|20\d{2})\b', text)
|
||||||
|
if match:
|
||||||
|
year = int(match.group(1))
|
||||||
|
logging.debug(f"Found year in info section: {year}")
|
||||||
|
return year
|
||||||
|
|
||||||
|
logging.debug(f"No year found for key: {key}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Error extracting year for key {key}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_key_html(self, key: str):
|
def _get_key_html(self, key: str):
|
||||||
"""Get cached HTML for series key.
|
"""Get cached HTML for series key.
|
||||||
|
|
||||||
|
|||||||
@ -693,10 +693,26 @@ async def add_series(
|
|||||||
detail="Could not extract series key from link",
|
detail="Could not extract series key from link",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step B: Create sanitized folder name from display name
|
# Step B: Fetch year from provider and create folder name with year
|
||||||
name = request.name.strip()
|
name = request.name.strip()
|
||||||
|
|
||||||
|
# Fetch year from provider
|
||||||
|
year = None
|
||||||
|
if series_app and hasattr(series_app, 'loader'):
|
||||||
|
try:
|
||||||
|
year = series_app.loader.get_year(key)
|
||||||
|
logger.info(f"Fetched year for {key}: {year}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not fetch year for {key}: {e}")
|
||||||
|
|
||||||
|
# Create folder name with year if available
|
||||||
|
if year:
|
||||||
|
folder_name_with_year = f"{name} ({year})"
|
||||||
|
else:
|
||||||
|
folder_name_with_year = name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
folder = sanitize_folder_name(name)
|
folder = sanitize_folder_name(folder_name_with_year)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
@ -729,14 +745,16 @@ async def add_series(
|
|||||||
name=name,
|
name=name,
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder=folder,
|
folder=folder,
|
||||||
|
year=year,
|
||||||
)
|
)
|
||||||
db_id = anime_series.id
|
db_id = anime_series.id
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Added series to database: %s (key=%s, db_id=%d)",
|
"Added series to database: %s (key=%s, db_id=%d, year=%s)",
|
||||||
name,
|
name,
|
||||||
key,
|
key,
|
||||||
db_id
|
db_id,
|
||||||
|
year
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step D: Add to SerieList (in-memory only, no folder creation)
|
# Step D: Add to SerieList (in-memory only, no folder creation)
|
||||||
@ -746,17 +764,19 @@ async def add_series(
|
|||||||
name=name,
|
name=name,
|
||||||
site="aniworld.to",
|
site="aniworld.to",
|
||||||
folder=folder,
|
folder=folder,
|
||||||
episodeDict={}
|
episodeDict={},
|
||||||
|
year=year
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add to in-memory cache without creating folder on disk
|
# Add to in-memory cache without creating folder on disk
|
||||||
if hasattr(series_app.list, 'keyDict'):
|
if hasattr(series_app.list, 'keyDict'):
|
||||||
series_app.list.keyDict[key] = serie
|
series_app.list.keyDict[key] = serie
|
||||||
logger.info(
|
logger.info(
|
||||||
"Added series to in-memory cache: %s (key=%s, folder=%s)",
|
"Added series to in-memory cache: %s (key=%s, folder=%s, year=%s)",
|
||||||
name,
|
name,
|
||||||
key,
|
key,
|
||||||
folder
|
folder,
|
||||||
|
year
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step E: Trigger targeted scan for missing episodes
|
# Step E: Trigger targeted scan for missing episodes
|
||||||
|
|||||||
@ -73,6 +73,10 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
String(1000), nullable=False,
|
String(1000), nullable=False,
|
||||||
doc="Filesystem folder name - METADATA ONLY, not for lookups"
|
doc="Filesystem folder name - METADATA ONLY, not for lookups"
|
||||||
)
|
)
|
||||||
|
year: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True,
|
||||||
|
doc="Release year of the series"
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
episodes: Mapped[List["Episode"]] = relationship(
|
episodes: Mapped[List["Episode"]] = relationship(
|
||||||
|
|||||||
@ -64,6 +64,7 @@ class AnimeSeriesService:
|
|||||||
name: str,
|
name: str,
|
||||||
site: str,
|
site: str,
|
||||||
folder: str,
|
folder: str,
|
||||||
|
year: int | None = None,
|
||||||
) -> AnimeSeries:
|
) -> AnimeSeries:
|
||||||
"""Create a new anime series.
|
"""Create a new anime series.
|
||||||
|
|
||||||
@ -73,6 +74,7 @@ class AnimeSeriesService:
|
|||||||
name: Series name
|
name: Series name
|
||||||
site: Provider site URL
|
site: Provider site URL
|
||||||
folder: Local filesystem path
|
folder: Local filesystem path
|
||||||
|
year: Release year (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Created AnimeSeries instance
|
Created AnimeSeries instance
|
||||||
@ -85,11 +87,12 @@ class AnimeSeriesService:
|
|||||||
name=name,
|
name=name,
|
||||||
site=site,
|
site=site,
|
||||||
folder=folder,
|
folder=folder,
|
||||||
|
year=year,
|
||||||
)
|
)
|
||||||
db.add(series)
|
db.add(series)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(series)
|
await db.refresh(series)
|
||||||
logger.info(f"Created anime series: {series.name} (key={series.key})")
|
logger.info(f"Created anime series: {series.name} (key={series.key}, year={year})")
|
||||||
return series
|
return series
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -594,6 +594,7 @@ class AnimeService:
|
|||||||
name=serie.name,
|
name=serie.name,
|
||||||
site=serie.site,
|
site=serie.site,
|
||||||
folder=serie.folder,
|
folder=serie.folder,
|
||||||
|
year=serie.year if hasattr(serie, 'year') else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Episode records
|
# Create Episode records
|
||||||
@ -608,9 +609,10 @@ class AnimeService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Created series in database: %s (key=%s)",
|
"Created series in database: %s (key=%s, year=%s)",
|
||||||
serie.name,
|
serie.name,
|
||||||
serie.key
|
serie.key,
|
||||||
|
serie.year if hasattr(serie, 'year') else None
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _update_series_in_db(self, serie, existing, db) -> None:
|
async def _update_series_in_db(self, serie, existing, db) -> None:
|
||||||
@ -768,6 +770,7 @@ class AnimeService:
|
|||||||
name=serie.name,
|
name=serie.name,
|
||||||
site=serie.site,
|
site=serie.site,
|
||||||
folder=serie.folder,
|
folder=serie.folder,
|
||||||
|
year=serie.year if hasattr(serie, 'year') else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Episode records for each episode in episodeDict
|
# Create Episode records for each episode in episodeDict
|
||||||
@ -782,9 +785,10 @@ class AnimeService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Added series to database: %s (key=%s)",
|
"Added series to database: %s (key=%s, year=%s)",
|
||||||
serie.name,
|
serie.name,
|
||||||
serie.key
|
serie.key,
|
||||||
|
serie.year if hasattr(serie, 'year') else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return anime_series
|
return anime_series
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user