syncfm-core 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/PKG-INFO +1 -1
  2. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/pyproject.toml +1 -1
  3. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/pyproject.toml.orig +1 -1
  4. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/dashboard.py +35 -6
  5. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/sync_progress.py +8 -2
  6. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/networks/pylast_adapter.py +41 -17
  7. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/metadata_service.py +8 -0
  8. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/submission_service.py +8 -0
  9. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/LICENSE +0 -0
  10. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/README.md +0 -0
  11. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/__init__.py +0 -0
  12. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/application.py +0 -0
  13. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/__init__.py +0 -0
  14. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/app.py +0 -0
  15. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/commands/__init__.py +0 -0
  16. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/commands/common.py +0 -0
  17. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/commands/discard.py +0 -0
  18. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/commands/profile.py +0 -0
  19. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/commands/resume.py +0 -0
  20. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/commands/status.py +0 -0
  21. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/commands/sync.py +0 -0
  22. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/configuration.py +0 -0
  23. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/context.py +0 -0
  24. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/presenters.py +0 -0
  25. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/cli/verification.py +0 -0
  26. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/exceptions.py +0 -0
  27. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/__init__.py +0 -0
  28. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/network_config.py +0 -0
  29. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/retry_policy.py +0 -0
  30. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/scrobble.py +0 -0
  31. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/sync_queue_item.py +0 -0
  32. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/sync_request.py +0 -0
  33. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/sync_result.py +0 -0
  34. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/sync_session.py +0 -0
  35. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/sync_status.py +0 -0
  36. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/models/sync_verification.py +0 -0
  37. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/networks/__init__.py +0 -0
  38. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/networks/factory.py +0 -0
  39. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/networks/gnufm.py +0 -0
  40. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/networks/pylast_mapper.py +0 -0
  41. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/profiles/__init__.py +0 -0
  42. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/profiles/factory.py +0 -0
  43. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/profiles/keyring_store.py +0 -0
  44. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/profiles/models.py +0 -0
  45. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/profiles/paths.py +0 -0
  46. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/profiles/protocols.py +0 -0
  47. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/profiles/resolver.py +0 -0
  48. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/profiles/service.py +0 -0
  49. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/profiles/toml_store.py +0 -0
  50. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/protocols/__init__.py +0 -0
  51. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/protocols/network.py +0 -0
  52. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/protocols/repositories.py +0 -0
  53. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/py.typed +0 -0
  54. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/repositories/__init__.py +0 -0
  55. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/repositories/database.py +0 -0
  56. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/repositories/queue_repository.py +0 -0
  57. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/repositories/schema.py +0 -0
  58. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/repositories/session_repository.py +0 -0
  59. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/repositories/snapshot_repository.py +0 -0
  60. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/__init__.py +0 -0
  61. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/comparison_service.py +0 -0
  62. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/range_resolver.py +0 -0
  63. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/recovery_service.py +0 -0
  64. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/retry_service.py +0 -0
  65. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/snapshot_service.py +0 -0
  66. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/sync_service.py +0 -0
  67. {syncfm_core-0.2.0 → syncfm_core-0.2.2}/src/syncfm_core/services/verification_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: syncfm-core
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Reusable synchronization engine and CLI for Last.fm, Libre.fm and GNU.fm networks.
5
5
  Keywords: lastfm,librefm,gnufm,scrobble,music
6
6
  Author: Hirad
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "syncfm-core"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Reusable synchronization engine and CLI for Last.fm, Libre.fm and GNU.fm networks."
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "syncfm-core"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Reusable synchronization engine and CLI for Last.fm, Libre.fm and GNU.fm networks."
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -64,6 +64,7 @@ class _RecentScrobble:
64
64
  scrobble: Scrobble
65
65
  phase: SyncPhase
66
66
  completed: bool
67
+ failed: bool
67
68
 
68
69
 
69
70
  class RichProgressPresenter:
@@ -124,7 +125,7 @@ class RichProgressPresenter:
124
125
  self._task_id: TaskID | None = None
125
126
  self._rendered_phase: SyncPhase | None = None
126
127
  self._live: Live | None = None
127
- self._last_plain_update: tuple[SyncPhase, int, int] | None = None
128
+ self._last_plain_update: tuple[SyncPhase, int, int, bool] | None = None
128
129
 
129
130
  def configure(self, *, source_label: str, target_label: str) -> None:
130
131
  """Update the source and target labels before rendering starts."""
@@ -364,11 +365,15 @@ class RichProgressPresenter:
364
365
  scrobble=scrobble,
365
366
  phase=progress.phase,
366
367
  completed=progress.current_scrobble_completed,
368
+ failed=progress.current_scrobble_failed,
367
369
  )
368
370
 
369
371
  @staticmethod
370
372
  def _recent_status(recent: _RecentScrobble) -> tuple[str, str]:
371
373
  """Return the label and Rich style for a recent-scrobble row."""
374
+ if recent.failed:
375
+ return "✗ FAILED", "bold red"
376
+
372
377
  if recent.completed:
373
378
  if recent.phase is SyncPhase.ENRICHING:
374
379
  return "✓ READY", "bold green"
@@ -394,24 +399,43 @@ class RichProgressPresenter:
394
399
 
395
400
  def _render_plain_update(self, progress: SyncProgress) -> None:
396
401
  """Print concise progress when output is not an interactive terminal."""
397
- snapshot = (progress.phase, progress.completed, progress.total)
402
+ snapshot = (
403
+ progress.phase,
404
+ progress.completed,
405
+ progress.total,
406
+ progress.current_scrobble_failed,
407
+ )
398
408
  phase_changed = (
399
409
  self._last_plain_update is None
400
410
  or self._last_plain_update[0] is not progress.phase
401
411
  )
402
- phase_finished = progress.total > 0 and progress.completed == progress.total
412
+ phase_finished = 0 < progress.total == progress.completed
403
413
 
404
- if not phase_changed and not phase_finished:
414
+ if (
415
+ not phase_changed
416
+ and not phase_finished
417
+ and not progress.current_scrobble_failed
418
+ ):
405
419
  return
406
420
 
407
421
  if snapshot == self._last_plain_update:
408
422
  return
409
423
 
410
424
  message = Text()
411
- message.append("● ", style=self._phase_color())
412
- message.append(_PHASE_LABELS[progress.phase])
425
+
426
+ if progress.current_scrobble_failed:
427
+ message.append("✗ ", style="bold red")
428
+ if progress.phase is SyncPhase.ENRICHING:
429
+ message.append("Metadata lookup failed", style="bold red")
430
+ else:
431
+ message.append("Scrobble submission failed", style="bold red")
432
+ else:
433
+ message.append("● ", style=self._phase_color())
434
+ message.append(_PHASE_LABELS[progress.phase])
435
+
413
436
  if progress.total > 0:
414
437
  message.append(f" {progress.completed}/{progress.total}", style="dim")
438
+
415
439
  self._console.print(message)
416
440
  self._last_plain_update = snapshot
417
441
 
@@ -425,6 +449,8 @@ class RichProgressPresenter:
425
449
 
426
450
  def _phase_color(self) -> str:
427
451
  """Return a consistent color for the current phase."""
452
+ if self._latest.current_scrobble_failed:
453
+ return "red"
428
454
  if self._latest.phase is SyncPhase.COMPLETED:
429
455
  return "green"
430
456
  if self._latest.phase is SyncPhase.FAILED:
@@ -433,6 +459,9 @@ class RichProgressPresenter:
433
459
 
434
460
  def _activity_badge(self) -> str:
435
461
  """Return a short uppercase status for the current activity."""
462
+ if self._latest.current_scrobble_failed:
463
+ return "FAILED"
464
+
436
465
  if self._latest.current_scrobble_completed:
437
466
  if self._latest.phase is SyncPhase.ENRICHING:
438
467
  return "READY"
@@ -16,6 +16,7 @@ class SyncProgress:
16
16
  total: int
17
17
  current_scrobble: Scrobble | None = None
18
18
  current_scrobble_completed: bool = False
19
+ current_scrobble_failed: bool = False
19
20
 
20
21
  def __post_init__(self) -> None:
21
22
  """Validate progress values."""
@@ -25,10 +26,15 @@ class SyncProgress:
25
26
  raise ValueError("total must be non-negative")
26
27
  if self.completed > self.total:
27
28
  raise ValueError("completed must not exceed total")
28
- if self.current_scrobble_completed and self.current_scrobble is None:
29
+ if (
30
+ self.current_scrobble_completed or self.current_scrobble_failed
31
+ ) and self.current_scrobble is None:
29
32
  raise ValueError(
30
- "Completed current-scrobble state requires a current scrobble."
33
+ "Completed or failed current-scrobble state requires a current scrobble."
31
34
  )
32
35
 
36
+ if self.current_scrobble_completed and self.current_scrobble_failed:
37
+ raise ValueError("A current scrobble cannot be both completed and failed.")
38
+
33
39
 
34
40
  type ProgressCallback = Callable[[SyncProgress], None]
@@ -133,34 +133,58 @@ class PyLastNetworkAdapter:
133
133
  end_timestamp: int | None = None,
134
134
  limit: int | None = None,
135
135
  ) -> list[Scrobble]:
136
- """Return scrobbles matching the supplied range or limit.
137
-
138
- Args:
139
- start_timestamp: Earliest playback timestamp to include.
140
- end_timestamp: Latest playback timestamp to include.
141
- limit: Maximum number of scrobbles to return.
136
+ """Return scrobbles within the inclusive timestamp range."""
137
+ api_start_timestamp = (
138
+ start_timestamp - 1
139
+ if start_timestamp is not None and start_timestamp > 0
140
+ else None
141
+ )
142
+ api_end_timestamp = (
143
+ end_timestamp + 1
144
+ if end_timestamp is not None
145
+ else None
146
+ )
142
147
 
143
- Returns:
144
- Matching playback events.
148
+ api_limit = (
149
+ None
150
+ if limit is not None
151
+ and (start_timestamp is not None or end_timestamp is not None)
152
+ else limit
153
+ )
145
154
 
146
- Raises:
147
- NetworkError: If pylast cannot retrieve playback history.
148
- InvalidNetworkResponseError: If returned playback data is malformed.
149
- """
150
155
  try:
151
156
  user = self._network.get_user(self._username)
152
157
  played_tracks = user.get_recent_tracks(
153
- limit=limit,
154
- time_from=start_timestamp,
155
- time_to=end_timestamp,
158
+ limit=api_limit,
159
+ time_from=api_start_timestamp,
160
+ time_to=api_end_timestamp,
156
161
  stream=False,
157
162
  )
158
- return [
159
- self._mapper.to_scrobble(played_track) for played_track in played_tracks
163
+ scrobbles = [
164
+ self._mapper.to_scrobble(played_track)
165
+ for played_track in played_tracks
160
166
  ]
161
167
  except pylast.PyLastError as error:
162
168
  raise self.translate_error(error) from error
163
169
 
170
+ filtered_scrobbles = [
171
+ scrobble
172
+ for scrobble in scrobbles
173
+ if (
174
+ start_timestamp is None
175
+ or scrobble.timestamp >= start_timestamp
176
+ )
177
+ and (
178
+ end_timestamp is None
179
+ or scrobble.timestamp <= end_timestamp
180
+ )
181
+ ]
182
+
183
+ if limit is not None:
184
+ return filtered_scrobbles[:limit]
185
+
186
+ return filtered_scrobbles
187
+
164
188
  def get_latest_scrobble_timestamp(self) -> int | None:
165
189
  """Return the latest scrobble timestamp, if one exists."""
166
190
  scrobbles = self.get_scrobbles(limit=1)
@@ -83,6 +83,12 @@ class MetadataService:
83
83
  item.id,
84
84
  self._error_message(error),
85
85
  )
86
+ self._report_progress(
87
+ completed,
88
+ total,
89
+ item.scrobble,
90
+ current_scrobble_failed=True,
91
+ )
86
92
  raise
87
93
 
88
94
  self._queue_repository.update_metadata(item.id, metadata)
@@ -103,6 +109,7 @@ class MetadataService:
103
109
  current_scrobble: Scrobble | None = None,
104
110
  *,
105
111
  current_scrobble_completed: bool = False,
112
+ current_scrobble_failed: bool = False,
106
113
  ) -> None:
107
114
  """Report metadata progress when a callback is configured."""
108
115
  if self._progress_callback is not None:
@@ -113,6 +120,7 @@ class MetadataService:
113
120
  total=total,
114
121
  current_scrobble=current_scrobble,
115
122
  current_scrobble_completed=current_scrobble_completed,
123
+ current_scrobble_failed=current_scrobble_failed,
116
124
  )
117
125
  )
118
126
 
@@ -82,6 +82,12 @@ class SubmissionService:
82
82
  item.id,
83
83
  self._error_message(error),
84
84
  )
85
+ self._report_progress(
86
+ completed,
87
+ total,
88
+ item.scrobble,
89
+ current_scrobble_failed=True,
90
+ )
85
91
  raise
86
92
 
87
93
  self._queue_repository.update_status(
@@ -101,6 +107,7 @@ class SubmissionService:
101
107
  current_scrobble: Scrobble | None = None,
102
108
  *,
103
109
  current_scrobble_completed: bool = False,
110
+ current_scrobble_failed: bool = False,
104
111
  ) -> None:
105
112
  """Report submission progress when a callback is configured."""
106
113
  if self._progress_callback is not None:
@@ -111,6 +118,7 @@ class SubmissionService:
111
118
  total=total,
112
119
  current_scrobble=current_scrobble,
113
120
  current_scrobble_completed=current_scrobble_completed,
121
+ current_scrobble_failed=current_scrobble_failed,
114
122
  )
115
123
  )
116
124
 
File without changes
File without changes