diff --git a/test/test_utils.py b/test/test_utils.py index aedb565ec1..09b40132b0 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -99,11 +99,13 @@ from yt_dlp.utils import ( remove_start, render_table, replace_extension, + datetime_round, rot47, sanitize_filename, sanitize_path, sanitize_url, shell_quote, + strftime_or_none, smuggle_url, str_to_int, strip_jsonp, @@ -407,6 +409,23 @@ class TestUtil(unittest.TestCase): self.assertEqual(datetime_from_str('now+1day', precision='hour'), datetime_from_str('now+24hours', precision='auto')) self.assertEqual(datetime_from_str('now+23hours', precision='hour'), datetime_from_str('now+23hours', precision='auto')) + def test_datetime_round(self): + self.assertEqual(datetime_round(dt.datetime.strptime('1820-05-12T01:23:45Z', '%Y-%m-%dT%H:%M:%SZ')), + dt.datetime(1820, 5, 12, tzinfo=dt.timezone.utc)) + self.assertEqual(datetime_round(dt.datetime.strptime('1969-12-31T23:34:45Z', '%Y-%m-%dT%H:%M:%SZ'), 'hour'), + dt.datetime(1970, 1, 1, 0, tzinfo=dt.timezone.utc)) + self.assertEqual(datetime_round(dt.datetime.strptime('2024-12-25T01:23:45Z', '%Y-%m-%dT%H:%M:%SZ'), 'minute'), + dt.datetime(2024, 12, 25, 1, 24, tzinfo=dt.timezone.utc)) + self.assertEqual(datetime_round(dt.datetime.strptime('2024-12-25T01:23:45.123Z', '%Y-%m-%dT%H:%M:%S.%fZ'), 'second'), + dt.datetime(2024, 12, 25, 1, 23, 45, tzinfo=dt.timezone.utc)) + self.assertEqual(datetime_round(dt.datetime.strptime('2024-12-25T01:23:45.678Z', '%Y-%m-%dT%H:%M:%S.%fZ'), 'second'), + dt.datetime(2024, 12, 25, 1, 23, 46, tzinfo=dt.timezone.utc)) + + def test_strftime_or_none(self): + self.assertEqual(strftime_or_none(-4722192000), '18200512') + self.assertEqual(strftime_or_none(0), '19700101') + self.assertEqual(strftime_or_none(1735084800), '20241225') + def test_daterange(self): _20century = DateRange('19000101', '20000101') self.assertFalse('17890714' in _20century) diff --git a/yt_dlp/YoutubeDL.py b/yt_dlp/YoutubeDL.py index 63e6e11b26..29cb1551dc 100644 --- a/yt_dlp/YoutubeDL.py +++ b/yt_dlp/YoutubeDL.py @@ -2692,11 +2692,7 @@ class YoutubeDL: ('modified_timestamp', 'modified_date'), ): if info_dict.get(date_key) is None and info_dict.get(ts_key) is not None: - # Working around out-of-range timestamp values (e.g. negative ones on Windows, - # see http://bugs.python.org/issue1646728) - with contextlib.suppress(ValueError, OverflowError, OSError): - upload_date = dt.datetime.fromtimestamp(info_dict[ts_key], dt.timezone.utc) - info_dict[date_key] = upload_date.strftime('%Y%m%d') + info_dict[date_key] = strftime_or_none(info_dict[ts_key]) if not info_dict.get('release_year'): info_dict['release_year'] = traverse_obj(info_dict, ('release_date', {lambda x: int(x[:4])})) diff --git a/yt_dlp/utils/_utils.py b/yt_dlp/utils/_utils.py index 99d7250876..5718584cb0 100644 --- a/yt_dlp/utils/_utils.py +++ b/yt_dlp/utils/_utils.py @@ -1369,6 +1369,13 @@ def datetime_add_months(dt_, months): return dt_.replace(year, month, day) +def datetime_from_timestamp(timestamp): + # Calling dt.datetime.fromtimestamp with negative timestamps throws error in Windows + # Ref: https://github.com/yt-dlp/yt-dlp/issues/5185, https://github.com/python/cpython/issues/94414, + # https://github.com/yt-dlp/yt-dlp/issues/6706#issuecomment-1496842642 + return (dt.datetime.fromtimestamp(0, dt.timezone.utc) + dt.timedelta(seconds=timestamp)) + + def datetime_round(dt_, precision='day'): """ Round a datetime object's time to a specific precision @@ -1376,6 +1383,7 @@ def datetime_round(dt_, precision='day'): if precision == 'microsecond': return dt_ + time_scale = 1_000_000 unit_seconds = { 'day': 86400, 'hour': 3600, @@ -1383,8 +1391,8 @@ def datetime_round(dt_, precision='day'): 'second': 1, } roundto = lambda x, n: ((x + n / 2) // n) * n - timestamp = roundto(calendar.timegm(dt_.timetuple()), unit_seconds[precision]) - return dt.datetime.fromtimestamp(timestamp, dt.timezone.utc) + timestamp = roundto(calendar.timegm(dt_.timetuple()) + dt_.microsecond / time_scale, unit_seconds[precision]) + return datetime_from_timestamp(timestamp) def hyphenate_date(date_str): @@ -2051,12 +2059,7 @@ def strftime_or_none(timestamp, date_format='%Y%m%d', default=None): datetime_object = None try: if isinstance(timestamp, (int, float)): # unix timestamp - # Using naive datetime here can break timestamp() in Windows - # Ref: https://github.com/yt-dlp/yt-dlp/issues/5185, https://github.com/python/cpython/issues/94414 - # Also, dt.datetime.fromtimestamp breaks for negative timestamps - # Ref: https://github.com/yt-dlp/yt-dlp/issues/6706#issuecomment-1496842642 - datetime_object = (dt.datetime.fromtimestamp(0, dt.timezone.utc) - + dt.timedelta(seconds=timestamp)) + datetime_object = datetime_from_timestamp(timestamp) elif isinstance(timestamp, str): # assume YYYYMMDD datetime_object = dt.datetime.strptime(timestamp, '%Y%m%d') date_format = re.sub( # Support %s on windows