diff --git a/src/nextcast/datetime_util.py b/src/nextcast/datetime_util.py index ebbc51f..91df485 100755 --- a/src/nextcast/datetime_util.py +++ b/src/nextcast/datetime_util.py @@ -3,6 +3,13 @@ from datetime import datetime, timezone +def test(): + utc_timestamp = 1699546201 + dt = from_timestamp(utc_timestamp) + assert dt == datetime(2023, 11, 9, 16, 10, 1, tzinfo=timezone.utc) + assert to_timestamp(dt) == utc_timestamp + + def now() -> datetime: return datetime.now(timezone.utc) @@ -16,7 +23,7 @@ def now_timestamp() -> int: def to_timestamp(dt: datetime) -> int: - return round(datetime.timestamp(dt)) * 1000 + return round(datetime.timestamp(dt)) def from_timestamp(timestamp: int) -> datetime: @@ -34,3 +41,7 @@ def from_str(dt_str: str) -> datetime: def fmt() -> str: return '%Y-%m-%dT%H:%M:%S' + + +if __name__ == '__main__': + test() diff --git a/src/nextcast/file_cache.py b/src/nextcast/file_cache.py index 9138c85..3c4be3a 100644 --- a/src/nextcast/file_cache.py +++ b/src/nextcast/file_cache.py @@ -56,6 +56,13 @@ def cache(file_url: str, cache_dir: Path) -> Path: """ file = hashed_location(file_url, cache_dir) + # Replace plain HTTP with HTTPS + http = 'http://' + https = 'https://' + if file_url.startswith(http): + file_url = https + file_url[len(http):] + assert file_url.startswith(https), file_url + # If the file is missing locally, it is downloaded. if not file.is_file(): Log.info(f'Downloading {file_url} ...') diff --git a/src/nextcast/nextcloud.py b/src/nextcast/nextcloud.py index edb1502..b7defe5 100644 --- a/src/nextcast/nextcloud.py +++ b/src/nextcast/nextcloud.py @@ -106,6 +106,9 @@ class EpisodeAction: def position(self, value: int): self._position = value + def __str__(self): + return json.dumps(self.to_json()) + class NextcloudApi: @classmethod @@ -156,22 +159,21 @@ class NextcloudApi: return content['add'] @classmethod - def fetch_play_actions(cls, server: str, login_name: str, app_password: str, user_id: str): + def fetch_play_actions(cls, server: str, login_name: str, app_password: str, user_id: str) -> list[EpisodeAction]: """ :return: Return new episode actions since last function call where EpisodeActionType is PLAY. """ timestamp_dict = read_timestamp() - timestamp = timestamp_dict.get(user_id, 0) + timestamp = datetime_util.from_str(timestamp_dict.get(user_id, '1970-01-01T00:00:00')) - episode_actions = NextcloudApi.fetch_episode_actions( + new_timestamp, episode_actions = NextcloudApi.fetch_episode_actions( server=server, login_name=login_name, app_password=app_password, types=[EpisodeActionType.PLAY], since=timestamp) - timestamp = datetime_util.now_timestamp() - timestamp_dict[user_id] = timestamp + timestamp_dict[user_id] = datetime_util.to_str(new_timestamp) write_timestamp(timestamp_dict) return episode_actions @@ -182,25 +184,26 @@ class NextcloudApi: login_name: str, app_password: str, types: list[EpisodeActionType] = None, - since: int = 0) -> list[EpisodeAction]: + since: datetime = datetime_util.from_timestamp(0)) \ + -> tuple[datetime, list[EpisodeAction]]: """ https://github.com/thrillfall/nextcloud-gpodder/blob/main/README.md#episode-action """ if types is None: types = [ea_type for ea_type in EpisodeActionType] - url = f'{server}/apps/gpoddersync/episode_action?since={since}' + url = f'{server}/apps/gpoddersync/episode_action?since={datetime_util.to_timestamp(since)}' Log.info(f'Downloading {url} ...') # auth parameter: https://requests.readthedocs.io/en/latest/user/authentication/#basic-authentication r = requests.get(url, auth=(login_name, app_password)) assert r.status_code == 200 content = json.loads(r.text) - _timestamp = datetime_util.from_timestamp(content['timestamp']) + timestamp = datetime_util.from_timestamp(content['timestamp']) episode_actions = [EpisodeAction.from_json(action) for action in content['actions']] - return [episode_action for episode_action in episode_actions - if episode_action.action in types] + return timestamp, [episode_action for episode_action in episode_actions + if episode_action.action in types] @classmethod def push_episode_actions(cls, server: str, login_name: str, app_password: str, diff --git a/src/nextcast/podcast.py b/src/nextcast/podcast.py index 504d213..58e0d31 100644 --- a/src/nextcast/podcast.py +++ b/src/nextcast/podcast.py @@ -73,13 +73,25 @@ class User: episodes_by_id: dict[str, Episode] = {e.episode_id: e for p in self._podcasts for e in p.episodes} + + # fix broken "guid" of podcast "Forklart" which includes heading and tailing newlines + for episode_action in episode_actions: + episode_action.guid = episode_action.guid.strip() + # fix local files on Android phone + header = 'antennapod_local:content://com.android' + episode_actions = [ea for ea in episode_actions if not ea.podcast.startswith(header)] + for episode_action in episode_actions: if episode_action.episode_id in episodes_by_id: episode = episodes_by_id[episode_action.episode_id] + print(f'\t{episode.title} {episode.get_position()} -> {episode_action.position}') activity = PlaybackActivity(self.user_id, episode_action.position, episode_action.timestamp) episode.set_activity(activity) episode.set_duration(episode_action.total) episode.write() + else: + # TODO + Log.error(f'Action refers locally unknown episode: {episode_action}') except requests.exceptions.ConnectionError as _e: Log.error(f'Could not fetch new actions.') @@ -266,6 +278,9 @@ class Episode: def set_activity(self, new_activity: PlaybackActivity): prev_activity = self.get_activity() + if new_activity.timestamp == prev_activity.timestamp and new_activity.position == prev_activity.position: + # This is the current activity, nothing has changed. + return if new_activity.timestamp <= prev_activity.timestamp: raise ValueError(f'Expected timestamp of new activity to be greater.' f' Previous: {prev_activity.timestamp}.'