Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to use the latest of creation and modification date for age #1162

Merged
merged 2 commits into from
Sep 9, 2024
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
1 change: 1 addition & 0 deletions changelog.d/860.change.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow to use the latest of creation and modification date to compute the age of the file.
13 changes: 12 additions & 1 deletion subliminal/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,16 @@ def cache(ctx: click.Context, clear_subliminal: bool) -> None:
@click.option('-p', '--provider', type=PROVIDER, multiple=True, help='Provider to use (can be used multiple times).')
@click.option('-r', '--refiner', type=REFINER, multiple=True, help='Refiner to use (can be used multiple times).')
@click.option('-a', '--age', type=AGE, help='Filter videos newer than AGE, e.g. 12h, 1w2d.')
@click.option(
'--use_creation_time',
'use_ctime',
is_flag=True,
default=False,
help=(
'Use the latest of modification date and creation date to calculate the age. '
'Otherwise, just use the modification date.'
),
)
@click.option(
'-d',
'--directory',
Expand Down Expand Up @@ -374,6 +384,7 @@ def download(
refiner: Sequence[str],
language: Sequence[Language],
age: timedelta | None,
use_ctime: bool,
directory: str | None,
encoding: str | None,
original_encoding: bool,
Expand Down Expand Up @@ -460,7 +471,7 @@ def download(
for video in video_candidates:
if not force:
video.subtitles |= set(search_external_subtitles(video.name, directory=directory).values())
if check_video(video, languages=language_set, age=age, undefined=single):
if check_video(video, languages=language_set, age=age, use_ctime=use_ctime, undefined=single):
refine(
video,
episode_refiners=refiner,
Expand Down
19 changes: 13 additions & 6 deletions subliminal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import os
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
from zipfile import BadZipfile

Expand All @@ -19,11 +18,12 @@
from .extensions import default_providers, provider_manager, refiner_manager
from .score import compute_score as default_compute_score
from .subtitle import SUBTITLE_EXTENSIONS, Subtitle
from .utils import handle_exception
from .utils import get_age, handle_exception
from .video import VIDEO_EXTENSIONS, Episode, Movie, Video

if TYPE_CHECKING:
from collections.abc import Iterator, Mapping, Sequence, Set
from datetime import timedelta
from types import TracebackType

from subliminal.providers import Provider
Expand Down Expand Up @@ -330,6 +330,7 @@ def check_video(
*,
languages: Set[Language] | None = None,
age: timedelta | None = None,
use_ctime: bool = False,
undefined: bool = False,
) -> bool:
"""Perform some checks on the `video`.
Expand All @@ -345,6 +346,8 @@ def check_video(
:param languages: desired languages.
:type languages: set of :class:`~babelfish.language.Language`
:param datetime.timedelta age: maximum age of the video.
:param bool use_ctime: use the latest of creation time and modification time to compute the age of the video,
instead of just modification time.
:param bool undefined: fail on existing undefined language.
:return: `True` if the video passes the checks, `False` otherwise.
:rtype: bool
Expand All @@ -356,7 +359,8 @@ def check_video(
return False

# age test
if age and video.age > age:
file_age = video.get_age(use_ctime=use_ctime)
if age and file_age > age:
logger.debug('Video is older than %r', age)
return False

Expand Down Expand Up @@ -539,6 +543,7 @@ def scan_videos(
path: str | os.PathLike,
*,
age: timedelta | None = None,
use_ctime: bool = False,
archives: bool = True,
name: str | None = None,
) -> list[Video]:
Expand All @@ -548,6 +553,8 @@ def scan_videos(

:param str path: existing directory path to scan.
:param datetime.timedelta age: maximum age of the video or archive.
:param bool use_ctime: use the latest of creation time and modification time to compute the age of the video,
instead of just modification time.
:param bool archives: scan videos in archives.
:param str name: name to use with guessit instead of the path.
:return: the scanned videos.
Expand Down Expand Up @@ -607,12 +614,12 @@ def scan_videos(

# skip old files
try:
file_age = datetime.fromtimestamp(os.path.getmtime(filepath), timezone.utc)
except ValueError: # pragma: no cover
file_age = get_age(filepath, use_ctime=use_ctime)
except ValueError:
logger.warning('Could not get age of file %r in %r', filename, dirpath)
continue
else:
if age and datetime.now(timezone.utc) - file_age > age:
if age and file_age > age:
logger.debug('Skipping old file %r in %r', filename, dirpath)
continue

Expand Down
54 changes: 53 additions & 1 deletion subliminal/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import functools
import logging
import os
import platform
import re
import socket
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from types import GeneratorType
from typing import TYPE_CHECKING, Any, Callable, Generic, Iterable, TypeVar, cast, overload
from xmlrpc.client import ProtocolError
Expand Down Expand Up @@ -181,3 +183,53 @@ def ensure_list(value: T | Sequence[T] | None) -> list[T]:
if not is_iterable(value):
return [cast(T, value)]
return list(value)


def modification_date(filepath: os.PathLike | str) -> float:
"""Get the modification date of the file."""
# Use the more cross-platform modification time.
return os.path.getmtime(filepath)


def creation_date(filepath: os.PathLike | str) -> float:
"""Get the creation date of the file.

Try to get the date that a file was created, falling back to when it was
last modified if that isn't possible.
See http://stackoverflow.com/a/39501288/1709587 for explanation.
"""
# Use creation time (although it may not be correct)
if platform.system() == 'Windows':
return os.path.getctime(filepath)
stat = os.stat(filepath)
try:
return stat.st_birthtime # type: ignore[no-any-return,attr-defined]
except AttributeError:
# We're probably on Linux. No easy way to get creation dates here,
# so we'll settle for when its content was last modified.
return stat.st_mtime


def get_age(
filepath: os.PathLike | str,
*,
reference_date: datetime | None = None,
use_ctime: bool = False,
) -> timedelta:
"""Get the age of the file from modification time (and creation time, optionally).

:param str filepath: the path of the file.
:param (datetime | None) reference_date: the datetime object to use as reference to calculate age.
Defaults to `datetime.now(timeinfo.utc)`.
:param bool use_ctime: if True, use the latest of modification and creation time to calculate age,
instead of using only the modification time.

"""
if not os.path.exists(filepath):
return timedelta()

file_date = modification_date(filepath)
if use_ctime:
file_date = max(file_date, creation_date(filepath))
reference_date = reference_date if reference_date is not None else datetime.now(timezone.utc)
return reference_date - datetime.fromtimestamp(file_date, timezone.utc)
18 changes: 13 additions & 5 deletions subliminal/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@

import logging
import os
from datetime import datetime, timedelta, timezone
import warnings
from typing import TYPE_CHECKING, Any, Sequence

from guessit import guessit # type: ignore[import-untyped]

from subliminal.utils import ensure_list, matches_title
from subliminal.utils import ensure_list, get_age, matches_title

if TYPE_CHECKING:
from collections.abc import Mapping, Set
from datetime import timedelta

from babelfish import Country, Language # type: ignore[import-untyped]

Expand Down Expand Up @@ -229,9 +230,12 @@ def exists(self) -> bool:
@property
def age(self) -> timedelta:
"""Age of the video."""
if not self.exists:
return timedelta()
return datetime.now(timezone.utc) - datetime.fromtimestamp(os.path.getmtime(self.name), timezone.utc)
warnings.warn(
'Use `get_age(use_ctime)` instead, to specify if modification time is used or also creation time.',
DeprecationWarning,
stacklevel=1,
)
return self.get_age(use_ctime=False)

@property
def subtitle_languages(self) -> set[Language]:
Expand Down Expand Up @@ -265,6 +269,10 @@ def fromname(cls, name: str) -> Video:
"""
return cls.fromguess(name, guessit(name))

def get_age(self, *, use_ctime: bool = False) -> timedelta:
"""Age of the video, with an option to take into account creation time."""
return get_age(self.name, use_ctime=use_ctime)

def __repr__(self) -> str:
return f'<{self.__class__.__name__} [{self.name!r}]>'

Expand Down
6 changes: 5 additions & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ def test_check_video_languages(movies):

def test_check_video_age(movies, monkeypatch):
video = movies['man_of_steel']
monkeypatch.setattr('subliminal.video.Video.age', timedelta(weeks=2))

def fake_age(*args: Any, **kwargs: Any) -> timedelta:
return timedelta(weeks=2)

monkeypatch.setattr('subliminal.video.Video.get_age', fake_age)
assert check_video(video, age=timedelta(weeks=3))
assert not check_video(video, age=timedelta(weeks=1))

Expand Down
37 changes: 36 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
from subliminal.utils import sanitize
import datetime
from typing import Any

from subliminal.utils import get_age, sanitize


def test_sanitize():
assert sanitize("Marvel's Agents of S.H.I.E.L.D.") == 'marvels agents of s h i e l d'


def test_get_age(monkeypatch) -> None:
NOW = datetime.datetime.now(datetime.timezone.utc)

# mock file age
def mock_modification_date(filepath: str, **kwargs: Any) -> float:
return (NOW - datetime.timedelta(weeks=2)).timestamp()

def mock_creation_date_later(*args: Any) -> float:
return (NOW - datetime.timedelta(weeks=1)).timestamp()

def mock_creation_date_sooner(*args: Any) -> float:
return (NOW - datetime.timedelta(weeks=3)).timestamp()

monkeypatch.setattr('subliminal.utils.modification_date', mock_modification_date)
monkeypatch.setattr('subliminal.utils.creation_date', mock_creation_date_later)

age = get_age(__file__, use_ctime=False, reference_date=NOW)
assert age == datetime.timedelta(weeks=2)

c_age = get_age(__file__, use_ctime=True, reference_date=NOW)
assert c_age == datetime.timedelta(weeks=1)

not_file_age = get_age('not-a-file.txt', reference_date=NOW)
assert not_file_age == datetime.timedelta()

# creation sooner
monkeypatch.setattr('subliminal.utils.creation_date', mock_creation_date_sooner)

c_age_2 = get_age(__file__, use_ctime=True, reference_date=NOW)
assert c_age_2 == datetime.timedelta(weeks=2)
Loading