syncfm-core 0.2.4__tar.gz → 0.3.0__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 (70) hide show
  1. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/PKG-INFO +46 -25
  2. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/README.md +40 -19
  3. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/pyproject.toml +9 -8
  4. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/pyproject.toml.orig +9 -8
  5. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/app.py +1 -1
  6. syncfm_core-0.3.0/src/syncfm_core/cli/commands/common.py +60 -0
  7. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/profile.py +1 -1
  8. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/resume.py +13 -14
  9. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/sync.py +17 -18
  10. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/context.py +6 -2
  11. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/verification.py +13 -14
  12. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/network_config.py +12 -3
  13. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/scrobble.py +2 -1
  14. syncfm_core-0.3.0/src/syncfm_core/networks/__init__.py +9 -0
  15. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/networks/factory.py +47 -26
  16. syncfm_core-0.3.0/src/syncfm_core/networks/pyscrobbler_adapter.py +176 -0
  17. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/models.py +6 -6
  18. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/database.py +13 -2
  19. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/queue_repository.py +4 -1
  20. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/schema.py +3 -1
  21. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/snapshot_repository.py +8 -3
  22. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/sync_service.py +16 -3
  23. syncfm_core-0.2.4/src/syncfm_core/cli/commands/common.py +0 -45
  24. syncfm_core-0.2.4/src/syncfm_core/networks/__init__.py +0 -13
  25. syncfm_core-0.2.4/src/syncfm_core/networks/gnufm.py +0 -93
  26. syncfm_core-0.2.4/src/syncfm_core/networks/pylast_adapter.py +0 -310
  27. syncfm_core-0.2.4/src/syncfm_core/networks/pylast_mapper.py +0 -76
  28. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/LICENSE +0 -0
  29. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/__init__.py +0 -0
  30. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/application.py +0 -0
  31. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/__init__.py +0 -0
  32. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/__init__.py +0 -0
  33. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/discard.py +0 -0
  34. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/status.py +0 -0
  35. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/configuration.py +0 -0
  36. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/dashboard.py +0 -0
  37. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/presenters.py +0 -0
  38. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/exceptions.py +0 -0
  39. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/__init__.py +0 -0
  40. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/retry_policy.py +0 -0
  41. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_progress.py +0 -0
  42. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_queue_item.py +0 -0
  43. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_request.py +0 -0
  44. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_result.py +0 -0
  45. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_session.py +0 -0
  46. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_status.py +0 -0
  47. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_verification.py +0 -0
  48. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/__init__.py +0 -0
  49. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/factory.py +0 -0
  50. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/keyring_store.py +0 -0
  51. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/paths.py +0 -0
  52. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/protocols.py +0 -0
  53. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/resolver.py +0 -0
  54. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/service.py +0 -0
  55. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/toml_store.py +0 -0
  56. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/protocols/__init__.py +0 -0
  57. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/protocols/network.py +0 -0
  58. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/protocols/repositories.py +0 -0
  59. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/py.typed +0 -0
  60. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/__init__.py +0 -0
  61. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/session_repository.py +0 -0
  62. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/__init__.py +0 -0
  63. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/comparison_service.py +0 -0
  64. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/metadata_service.py +0 -0
  65. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/range_resolver.py +0 -0
  66. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/recovery_service.py +0 -0
  67. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/retry_service.py +0 -0
  68. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/snapshot_service.py +0 -0
  69. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/submission_service.py +0 -0
  70. {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/verification_service.py +0 -0
@@ -1,24 +1,24 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: syncfm-core
3
- Version: 0.2.4
4
- Summary: Reusable synchronization engine and CLI for Last.fm, Libre.fm and GNU.fm networks.
5
- Keywords: lastfm,librefm,gnufm,scrobble,music
3
+ Version: 0.3.0
4
+ Summary: Reusable synchronization engine and CLI for Last.fm, Libre.fm and LoneFM networks.
5
+ Keywords: lastfm,librefm,lonefm,scrobble,music
6
6
  Author: Hirad
7
7
  Author-email: Hirad <hirad@hirad.it>
8
8
  License-Expression: GPL-3.0-or-later
9
9
  License-File: LICENSE
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
13
  Classifier: Operating System :: OS Independent
14
14
  Requires-Dist: keyring>=25.7.0
15
15
  Requires-Dist: platformdirs>=4.11.1
16
- Requires-Dist: pylast>=7.1.0
16
+ Requires-Dist: pyscrobbler>=0.1.5
17
17
  Requires-Dist: rich>=15.0.0
18
18
  Requires-Dist: socksio>=1.0.0
19
19
  Requires-Dist: tomlkit>=0.15.1
20
20
  Requires-Dist: typer>=0.27.1
21
- Requires-Python: >=3.12
21
+ Requires-Python: >=3.13
22
22
  Project-URL: Homepage, https://git.hirad.it/Hirad/syncfm-core
23
23
  Project-URL: Documentation, https://git.hirad.it/Hirad/syncfm-core/wiki
24
24
  Project-URL: Repository, https://git.hirad.it/Hirad/syncfm-core
@@ -28,7 +28,7 @@ Description-Content-Type: text/markdown
28
28
  # syncfm-core
29
29
 
30
30
  `syncfm-core` synchronizes missing scrobbles between Last.fm, Libre.fm, and
31
- GNU.fm-compatible networks. It includes both a reusable, typed Python
31
+ LoneFM-compatible networks. It includes both a reusable, typed Python
32
32
  application layer and the `syncfm` command-line interface.
33
33
 
34
34
  The project is currently beta software. Keep a copy of important listening
@@ -37,7 +37,7 @@ history before using it with an account you cannot easily restore.
37
37
  ## Features
38
38
 
39
39
  - Directional synchronization from a source profile to a target profile
40
- - Last.fm, Libre.fm, and custom GNU.fm-compatible instances
40
+ - Last.fm, Libre.fm, and custom LoneFM-compatible instances
41
41
  - Optional start and end timestamps for bounded synchronization
42
42
  - Durable SQLite sessions that can be inspected, resumed, or discarded
43
43
  - Metadata enrichment and retries for transient network failures
@@ -48,7 +48,7 @@ history before using it with an account you cannot easily restore.
48
48
 
49
49
  ## Requirements
50
50
 
51
- - Python 3.12 or newer
51
+ - Python 3.13 or newer
52
52
  - API credentials for every configured network
53
53
  - A working system keyring when using the default credential storage
54
54
  - [`uv`](https://docs.astral.sh/uv/) for the development workflow shown below
@@ -78,9 +78,7 @@ checkout instead of an installed tool.
78
78
 
79
79
  ### 1. Create network profiles
80
80
 
81
- Create one profile for each account. Profile names must start with a lowercase
82
- letter or number and may contain lowercase letters, numbers, underscores, and
83
- hyphens.
81
+ Create one profile for each account.
84
82
 
85
83
  ```console
86
84
  syncfm profile add lastfm-main \
@@ -99,11 +97,11 @@ syncfm profile add librefm-main \
99
97
  --api-key YOUR_LIBREFM_API_KEY
100
98
  ```
101
99
 
102
- GNU.fm profiles also require the instance hostname:
100
+ LoneFM profiles also require the instance hostname:
103
101
 
104
102
  ```console
105
103
  syncfm profile add community \
106
- --type gnufm \
104
+ --type lonefm \
107
105
  --hostname music.example.org \
108
106
  --username alice \
109
107
  --api-key YOUR_API_KEY
@@ -238,6 +236,7 @@ The CLI is a frontend for the same public application services available to
238
236
  Python callers:
239
237
 
240
238
  ```python
239
+ from contextlib import ExitStack, closing
241
240
  from pathlib import Path
242
241
 
243
242
  from syncfm_core import (
@@ -250,17 +249,22 @@ from syncfm_core import (
250
249
  profiles = create_profile_service()
251
250
  factory = NetworkFactory()
252
251
 
253
- source = factory.create(profiles.resolve("lastfm-main"))
254
- target = factory.create(profiles.resolve("librefm-main"))
255
-
256
- service = create_sync_service(Path("syncfm.sqlite3"))
257
- result = service.sync(
258
- source,
259
- target,
260
- SyncRequest(start_timestamp=1704067200),
261
- source_profile="lastfm-main",
262
- target_profile="librefm-main",
263
- )
252
+ with ExitStack() as stack:
253
+ source = stack.enter_context(
254
+ closing(factory.create(profiles.resolve("lastfm-main")))
255
+ )
256
+ target = stack.enter_context(
257
+ closing(factory.create(profiles.resolve("librefm-main")))
258
+ )
259
+
260
+ service = create_sync_service(Path("syncfm.sqlite3"))
261
+ result = service.sync(
262
+ source,
263
+ target,
264
+ SyncRequest(start_timestamp=1704067200),
265
+ source_profile="lastfm-main",
266
+ target_profile="librefm-main",
267
+ )
264
268
 
265
269
  print(f"Synchronized {result.synced_count} scrobbles")
266
270
  print(f"Failed: {result.failed_count}")
@@ -270,6 +274,21 @@ print(f"Failed: {result.failed_count}")
270
274
  The package also exports its domain models, network protocols, profile service,
271
275
  and structured exception hierarchy for custom frontends and integrations.
272
276
 
277
+ ### Migrating from pylast
278
+
279
+ NetworkFactory now returns a PyScrobblerNetworkAdapter. Python callers should
280
+ close adapters when finished, as shown above. The MusicNetwork protocol and
281
+ syncfm's scrobble models remain the application interface.
282
+
283
+ The integration exports GNUFMNetwork, PyLastMapper, and PyLastNetworkAdapter
284
+ and their modules have been removed. Replace direct usage with NetworkFactory
285
+ and PyScrobblerNetworkAdapter from syncfm_core.networks.
286
+
287
+ Use NetworkType.LONEFM and `--type lonefm` for custom instances. The former
288
+ NetworkType.GNUFM enum member has been removed. Existing profiles with the
289
+ legacy type value and saved sessions with the former display name remain
290
+ readable; new profiles and sessions use LoneFM naming.
291
+
273
292
  ## Command reference
274
293
 
275
294
  ```text
@@ -293,7 +312,9 @@ Create the environment and run the project checks:
293
312
  ```console
294
313
  uv sync
295
314
  uv run ruff check .
315
+ uv run ruff format --check .
296
316
  uv run pyright
317
+ uv run pytest
297
318
  uv build
298
319
  ```
299
320
 
@@ -1,7 +1,7 @@
1
1
  # syncfm-core
2
2
 
3
3
  `syncfm-core` synchronizes missing scrobbles between Last.fm, Libre.fm, and
4
- GNU.fm-compatible networks. It includes both a reusable, typed Python
4
+ LoneFM-compatible networks. It includes both a reusable, typed Python
5
5
  application layer and the `syncfm` command-line interface.
6
6
 
7
7
  The project is currently beta software. Keep a copy of important listening
@@ -10,7 +10,7 @@ history before using it with an account you cannot easily restore.
10
10
  ## Features
11
11
 
12
12
  - Directional synchronization from a source profile to a target profile
13
- - Last.fm, Libre.fm, and custom GNU.fm-compatible instances
13
+ - Last.fm, Libre.fm, and custom LoneFM-compatible instances
14
14
  - Optional start and end timestamps for bounded synchronization
15
15
  - Durable SQLite sessions that can be inspected, resumed, or discarded
16
16
  - Metadata enrichment and retries for transient network failures
@@ -21,7 +21,7 @@ history before using it with an account you cannot easily restore.
21
21
 
22
22
  ## Requirements
23
23
 
24
- - Python 3.12 or newer
24
+ - Python 3.13 or newer
25
25
  - API credentials for every configured network
26
26
  - A working system keyring when using the default credential storage
27
27
  - [`uv`](https://docs.astral.sh/uv/) for the development workflow shown below
@@ -51,9 +51,7 @@ checkout instead of an installed tool.
51
51
 
52
52
  ### 1. Create network profiles
53
53
 
54
- Create one profile for each account. Profile names must start with a lowercase
55
- letter or number and may contain lowercase letters, numbers, underscores, and
56
- hyphens.
54
+ Create one profile for each account.
57
55
 
58
56
  ```console
59
57
  syncfm profile add lastfm-main \
@@ -72,11 +70,11 @@ syncfm profile add librefm-main \
72
70
  --api-key YOUR_LIBREFM_API_KEY
73
71
  ```
74
72
 
75
- GNU.fm profiles also require the instance hostname:
73
+ LoneFM profiles also require the instance hostname:
76
74
 
77
75
  ```console
78
76
  syncfm profile add community \
79
- --type gnufm \
77
+ --type lonefm \
80
78
  --hostname music.example.org \
81
79
  --username alice \
82
80
  --api-key YOUR_API_KEY
@@ -211,6 +209,7 @@ The CLI is a frontend for the same public application services available to
211
209
  Python callers:
212
210
 
213
211
  ```python
212
+ from contextlib import ExitStack, closing
214
213
  from pathlib import Path
215
214
 
216
215
  from syncfm_core import (
@@ -223,17 +222,22 @@ from syncfm_core import (
223
222
  profiles = create_profile_service()
224
223
  factory = NetworkFactory()
225
224
 
226
- source = factory.create(profiles.resolve("lastfm-main"))
227
- target = factory.create(profiles.resolve("librefm-main"))
228
-
229
- service = create_sync_service(Path("syncfm.sqlite3"))
230
- result = service.sync(
231
- source,
232
- target,
233
- SyncRequest(start_timestamp=1704067200),
234
- source_profile="lastfm-main",
235
- target_profile="librefm-main",
236
- )
225
+ with ExitStack() as stack:
226
+ source = stack.enter_context(
227
+ closing(factory.create(profiles.resolve("lastfm-main")))
228
+ )
229
+ target = stack.enter_context(
230
+ closing(factory.create(profiles.resolve("librefm-main")))
231
+ )
232
+
233
+ service = create_sync_service(Path("syncfm.sqlite3"))
234
+ result = service.sync(
235
+ source,
236
+ target,
237
+ SyncRequest(start_timestamp=1704067200),
238
+ source_profile="lastfm-main",
239
+ target_profile="librefm-main",
240
+ )
237
241
 
238
242
  print(f"Synchronized {result.synced_count} scrobbles")
239
243
  print(f"Failed: {result.failed_count}")
@@ -243,6 +247,21 @@ print(f"Failed: {result.failed_count}")
243
247
  The package also exports its domain models, network protocols, profile service,
244
248
  and structured exception hierarchy for custom frontends and integrations.
245
249
 
250
+ ### Migrating from pylast
251
+
252
+ NetworkFactory now returns a PyScrobblerNetworkAdapter. Python callers should
253
+ close adapters when finished, as shown above. The MusicNetwork protocol and
254
+ syncfm's scrobble models remain the application interface.
255
+
256
+ The integration exports GNUFMNetwork, PyLastMapper, and PyLastNetworkAdapter
257
+ and their modules have been removed. Replace direct usage with NetworkFactory
258
+ and PyScrobblerNetworkAdapter from syncfm_core.networks.
259
+
260
+ Use NetworkType.LONEFM and `--type lonefm` for custom instances. The former
261
+ NetworkType.GNUFM enum member has been removed. Existing profiles with the
262
+ legacy type value and saved sessions with the former display name remain
263
+ readable; new profiles and sessions use LoneFM naming.
264
+
246
265
  ## Command reference
247
266
 
248
267
  ```text
@@ -266,7 +285,9 @@ Create the environment and run the project checks:
266
285
  ```console
267
286
  uv sync
268
287
  uv run ruff check .
288
+ uv run ruff format --check .
269
289
  uv run pyright
290
+ uv run pytest
270
291
  uv build
271
292
  ```
272
293
 
@@ -1,28 +1,28 @@
1
1
  [project]
2
2
  name = "syncfm-core"
3
- version = "0.2.4"
4
- description = "Reusable synchronization engine and CLI for Last.fm, Libre.fm and GNU.fm networks."
3
+ version = "0.3.0"
4
+ description = "Reusable synchronization engine and CLI for Last.fm, Libre.fm and LoneFM networks."
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
7
7
  license-files = ["LICENSE"]
8
8
  keywords = [
9
9
  "lastfm",
10
10
  "librefm",
11
- "gnufm",
11
+ "lonefm",
12
12
  "scrobble",
13
13
  "music",
14
14
  ]
15
15
  classifiers = [
16
16
  "Development Status :: 4 - Beta",
17
17
  "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
19
  "Operating System :: OS Independent",
20
20
  ]
21
- requires-python = ">=3.12"
21
+ requires-python = ">=3.13"
22
22
  dependencies = [
23
23
  "keyring>=25.7.0",
24
24
  "platformdirs>=4.11.1",
25
- "pylast>=7.1.0",
25
+ "pyscrobbler>=0.1.5",
26
26
  "rich>=15.0.0",
27
27
  "socksio>=1.0.0",
28
28
  "tomlkit>=0.15.1",
@@ -49,17 +49,18 @@ build-backend = "uv_build"
49
49
  [dependency-groups]
50
50
  dev = [
51
51
  "pyright>=1.1.411",
52
+ "pytest>=9.1.1",
52
53
  "ruff>=0.16.2",
53
54
  ]
54
55
 
55
56
  [tool.pyright]
56
- pythonVersion = "3.12"
57
+ pythonVersion = "3.13"
57
58
  typeCheckingMode = "strict"
58
59
  venvPath = "."
59
60
  venv = ".venv"
60
61
 
61
62
  [tool.ruff]
62
- target-version = "py312"
63
+ target-version = "py313"
63
64
  line-length = 88
64
65
  indent-width = 4
65
66
 
@@ -1,25 +1,25 @@
1
1
  [project]
2
2
  name = "syncfm-core"
3
- version = "0.2.4"
4
- description = "Reusable synchronization engine and CLI for Last.fm, Libre.fm and GNU.fm networks."
3
+ version = "0.3.0"
4
+ description = "Reusable synchronization engine and CLI for Last.fm, Libre.fm and LoneFM networks."
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
7
7
  license-files = ["LICENSE"]
8
8
  authors = [
9
9
  { name = "Hirad", email = "hirad@hirad.it" }
10
10
  ]
11
- keywords = ["lastfm", "librefm", "gnufm", "scrobble", "music"]
11
+ keywords = ["lastfm", "librefm", "lonefm", "scrobble", "music"]
12
12
  classifiers = [
13
13
  "Development Status :: 4 - Beta",
14
14
  "Programming Language :: Python :: 3",
15
- "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
16
  "Operating System :: OS Independent",
17
17
  ]
18
- requires-python = ">=3.12"
18
+ requires-python = ">=3.13"
19
19
  dependencies = [
20
20
  "keyring>=25.7.0",
21
21
  "platformdirs>=4.11.1",
22
- "pylast>=7.1.0",
22
+ "pyscrobbler>=0.1.5",
23
23
  "rich>=15.0.0",
24
24
  "socksio>=1.0.0",
25
25
  "tomlkit>=0.15.1",
@@ -42,17 +42,18 @@ build-backend = "uv_build"
42
42
  [dependency-groups]
43
43
  dev = [
44
44
  "pyright>=1.1.411",
45
+ "pytest>=9.1.1",
45
46
  "ruff>=0.16.2",
46
47
  ]
47
48
 
48
49
  [tool.pyright]
49
- pythonVersion = "3.12"
50
+ pythonVersion = "3.13"
50
51
  typeCheckingMode = "strict"
51
52
  venvPath = "."
52
53
  venv = ".venv"
53
54
 
54
55
  [tool.ruff]
55
- target-version = "py312"
56
+ target-version = "py313"
56
57
  line-length = 88
57
58
  indent-width = 4
58
59
 
@@ -25,7 +25,7 @@ DEFAULT_PROFILE_PATH = default_profile_path()
25
25
 
26
26
  app = typer.Typer(
27
27
  name="syncfm",
28
- help="Synchronize scrobbles between Last.fm, Libre.fm and GNU.fm networks.",
28
+ help="Synchronize scrobbles between Last.fm, Libre.fm and LoneFM networks.",
29
29
  no_args_is_help=True,
30
30
  rich_markup_mode="rich",
31
31
  context_settings={
@@ -0,0 +1,60 @@
1
+ """Shared helpers for network-based CLI commands."""
2
+
3
+ from collections.abc import Generator
4
+ from contextlib import ExitStack, contextmanager
5
+ from functools import partial
6
+
7
+ from syncfm_core.cli.context import CLIContext, run_core_operation
8
+ from syncfm_core.networks.factory import NetworkFactory
9
+ from syncfm_core.networks.pyscrobbler_adapter import PyScrobblerNetworkAdapter
10
+ from syncfm_core.profiles import create_profile_service
11
+
12
+
13
+ @contextmanager
14
+ def create_network_pair(
15
+ context: CLIContext,
16
+ source_profile: str,
17
+ target_profile: str,
18
+ ) -> Generator[tuple[PyScrobblerNetworkAdapter, PyScrobblerNetworkAdapter]]:
19
+ """Create two networks and close them when command execution ends."""
20
+ profile_service = create_profile_service(context.config_path)
21
+
22
+ source_config = run_core_operation(
23
+ context,
24
+ partial(
25
+ profile_service.resolve,
26
+ source_profile,
27
+ ),
28
+ )
29
+ target_config = run_core_operation(
30
+ context,
31
+ partial(
32
+ profile_service.resolve,
33
+ target_profile,
34
+ ),
35
+ )
36
+
37
+ factory = NetworkFactory()
38
+
39
+ with ExitStack() as stack:
40
+ source_network = run_core_operation(
41
+ context,
42
+ partial(factory.create, source_config),
43
+ )
44
+ stack.callback(
45
+ run_core_operation,
46
+ context,
47
+ source_network.close,
48
+ )
49
+
50
+ target_network = run_core_operation(
51
+ context,
52
+ partial(factory.create, target_config),
53
+ )
54
+ stack.callback(
55
+ run_core_operation,
56
+ context,
57
+ target_network.close,
58
+ )
59
+
60
+ yield source_network, target_network
@@ -35,7 +35,7 @@ def add_profile_command(
35
35
  typer.Option("--api-key", "--api_key", help="Network API key."),
36
36
  ],
37
37
  hostname: Annotated[
38
- str | None, typer.Option("--hostname", help="GNU.fm instance hostname.")
38
+ str | None, typer.Option("--hostname", help="LoneFM instance hostname.")
39
39
  ] = None,
40
40
  proxy: Annotated[
41
41
  str | None,
@@ -96,13 +96,11 @@ def resume_command(
96
96
  source_label=resolved_source_profile,
97
97
  target_label=resolved_target_profile,
98
98
  )
99
- source_network, target_network = create_network_pair(
99
+ with create_network_pair(
100
100
  cli_context,
101
101
  resolved_source_profile,
102
102
  resolved_target_profile,
103
- )
104
-
105
- with progress_presenter:
103
+ ) as (source_network, target_network):
106
104
  result = run_core_operation(
107
105
  cli_context,
108
106
  partial(
@@ -113,15 +111,16 @@ def resume_command(
113
111
  source_profile=resolved_source_profile,
114
112
  target_profile=resolved_target_profile,
115
113
  ),
114
+ progress=progress_presenter,
116
115
  )
117
116
 
118
- present_result_and_retry_missing(
119
- cli_context,
120
- sync_service,
121
- source_network,
122
- target_network,
123
- result,
124
- progress_presenter,
125
- source_profile=resolved_source_profile,
126
- target_profile=resolved_target_profile,
127
- )
117
+ present_result_and_retry_missing(
118
+ cli_context,
119
+ sync_service,
120
+ source_network,
121
+ target_network,
122
+ result,
123
+ progress_presenter,
124
+ source_profile=resolved_source_profile,
125
+ target_profile=resolved_target_profile,
126
+ )
@@ -81,18 +81,16 @@ def sync_command(
81
81
  )
82
82
  raise typer.Exit(code=2)
83
83
 
84
- source_network, target_network = create_network_pair(
84
+ with create_network_pair(
85
85
  cli_context,
86
86
  source_profile,
87
87
  target_profile,
88
- )
89
-
90
- request = SyncRequest(
91
- start_timestamp=start_timestamp,
92
- end_timestamp=end_timestamp,
93
- )
88
+ ) as (source_network, target_network):
89
+ request = SyncRequest(
90
+ start_timestamp=start_timestamp,
91
+ end_timestamp=end_timestamp,
92
+ )
94
93
 
95
- with progress_presenter:
96
94
  result = run_core_operation(
97
95
  cli_context,
98
96
  partial(
@@ -103,15 +101,16 @@ def sync_command(
103
101
  source_profile=source_profile,
104
102
  target_profile=target_profile,
105
103
  ),
104
+ progress=progress_presenter,
106
105
  )
107
106
 
108
- present_result_and_retry_missing(
109
- cli_context,
110
- sync_service,
111
- source_network,
112
- target_network,
113
- result,
114
- progress_presenter,
115
- source_profile=source_profile,
116
- target_profile=target_profile,
117
- )
107
+ present_result_and_retry_missing(
108
+ cli_context,
109
+ sync_service,
110
+ source_network,
111
+ target_network,
112
+ result,
113
+ progress_presenter,
114
+ source_profile=source_profile,
115
+ target_profile=target_profile,
116
+ )
@@ -2,6 +2,7 @@
2
2
 
3
3
  import logging
4
4
  from collections.abc import Callable
5
+ from contextlib import AbstractContextManager, nullcontext
5
6
  from dataclasses import dataclass
6
7
  from pathlib import Path
7
8
 
@@ -40,10 +41,13 @@ def get_cli_context(context: typer.Context) -> CLIContext:
40
41
  def run_core_operation[T](
41
42
  context: CLIContext,
42
43
  operation: Callable[[], T],
44
+ *,
45
+ progress: AbstractContextManager[object] | None = None,
43
46
  ) -> T:
44
- """Execute an application operation with user-facing error handling."""
47
+ """Execute an operation, restoring progress output before reporting errors."""
45
48
  try:
46
- return operation()
49
+ with progress if progress is not None else nullcontext():
50
+ return operation()
47
51
  except SyncFMCoreError as error:
48
52
  message = str(error).strip() or type(error).__name__
49
53
  context.error_console.print(
@@ -45,8 +45,7 @@ def present_result_and_retry_missing(
45
45
  )
46
46
 
47
47
  should_retry = typer.confirm(
48
- f"Try synchronizing these {len(missing)} "
49
- "missing scrobble(s) again?",
48
+ f"Try synchronizing these {len(missing)} missing scrobble(s) again?",
50
49
  default=True,
51
50
  )
52
51
 
@@ -62,15 +61,15 @@ def present_result_and_retry_missing(
62
61
  end_timestamp=verification.end_timestamp,
63
62
  )
64
63
 
65
- with progress_presenter:
66
- result = run_core_operation(
67
- context,
68
- partial(
69
- sync_service.sync,
70
- source,
71
- target,
72
- retry_request,
73
- source_profile=source_profile,
74
- target_profile=target_profile,
75
- ),
76
- )
64
+ result = run_core_operation(
65
+ context,
66
+ partial(
67
+ sync_service.sync,
68
+ source,
69
+ target,
70
+ retry_request,
71
+ source_profile=source_profile,
72
+ target_profile=target_profile,
73
+ ),
74
+ progress=progress_presenter,
75
+ )
@@ -1,5 +1,7 @@
1
1
  """Domain models defining supported music networks and their configurations."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from dataclasses import dataclass
4
6
  from enum import StrEnum
5
7
 
@@ -9,7 +11,7 @@ class NetworkType(StrEnum):
9
11
 
10
12
  LASTFM = "lastfm"
11
13
  LIBREFM = "librefm"
12
- GNUFM = "gnufm"
14
+ LONEFM = "lonefm"
13
15
 
14
16
  @property
15
17
  def display_name(self) -> str:
@@ -19,8 +21,15 @@ class NetworkType(StrEnum):
19
21
  return "Last.fm"
20
22
  case NetworkType.LIBREFM:
21
23
  return "Libref.fm"
22
- case NetworkType.GNUFM:
23
- return "GNU.fm"
24
+ case NetworkType.LONEFM:
25
+ return "LoneFM"
26
+
27
+ @classmethod
28
+ def _missing_(cls, value: object) -> NetworkType | None:
29
+ """Accept the legacy GNUFM configuration value."""
30
+ if value == "gnufm":
31
+ return cls.LONEFM
32
+ return None
24
33
 
25
34
 
26
35
  @dataclass(frozen=True, slots=True)
@@ -1,6 +1,6 @@
1
1
  """Domain models representing scrobbles and their associated track metadata."""
2
2
 
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
 
5
5
 
6
6
  @dataclass(frozen=True, slots=True)
@@ -20,3 +20,4 @@ class Scrobble:
20
20
  timestamp: int
21
21
  album: str | None = None
22
22
  metadata: TrackMetadata | None = None
23
+ image: str | None = field(default=None, kw_only=True)