syncfm-core 0.2.5__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.5 → syncfm_core-0.3.0}/PKG-INFO +45 -22
  2. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/README.md +39 -16
  3. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/pyproject.toml +9 -8
  4. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/pyproject.toml.orig +9 -8
  5. {syncfm_core-0.2.5 → 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.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/profile.py +1 -1
  8. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/resume.py +13 -14
  9. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/sync.py +17 -18
  10. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/context.py +6 -2
  11. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/verification.py +13 -14
  12. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/network_config.py +12 -3
  13. {syncfm_core-0.2.5 → 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.5 → 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.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/models.py +3 -3
  18. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/database.py +13 -2
  19. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/queue_repository.py +4 -1
  20. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/schema.py +3 -1
  21. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/snapshot_repository.py +8 -3
  22. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/sync_service.py +16 -3
  23. syncfm_core-0.2.5/src/syncfm_core/cli/commands/common.py +0 -45
  24. syncfm_core-0.2.5/src/syncfm_core/networks/__init__.py +0 -13
  25. syncfm_core-0.2.5/src/syncfm_core/networks/gnufm.py +0 -93
  26. syncfm_core-0.2.5/src/syncfm_core/networks/pylast_adapter.py +0 -310
  27. syncfm_core-0.2.5/src/syncfm_core/networks/pylast_mapper.py +0 -76
  28. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/LICENSE +0 -0
  29. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/__init__.py +0 -0
  30. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/application.py +0 -0
  31. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/__init__.py +0 -0
  32. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/__init__.py +0 -0
  33. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/discard.py +0 -0
  34. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/status.py +0 -0
  35. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/configuration.py +0 -0
  36. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/dashboard.py +0 -0
  37. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/presenters.py +0 -0
  38. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/exceptions.py +0 -0
  39. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/__init__.py +0 -0
  40. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/retry_policy.py +0 -0
  41. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_progress.py +0 -0
  42. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_queue_item.py +0 -0
  43. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_request.py +0 -0
  44. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_result.py +0 -0
  45. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_session.py +0 -0
  46. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_status.py +0 -0
  47. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_verification.py +0 -0
  48. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/__init__.py +0 -0
  49. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/factory.py +0 -0
  50. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/keyring_store.py +0 -0
  51. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/paths.py +0 -0
  52. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/protocols.py +0 -0
  53. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/resolver.py +0 -0
  54. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/service.py +0 -0
  55. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/toml_store.py +0 -0
  56. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/protocols/__init__.py +0 -0
  57. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/protocols/network.py +0 -0
  58. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/protocols/repositories.py +0 -0
  59. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/py.typed +0 -0
  60. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/__init__.py +0 -0
  61. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/session_repository.py +0 -0
  62. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/__init__.py +0 -0
  63. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/comparison_service.py +0 -0
  64. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/metadata_service.py +0 -0
  65. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/range_resolver.py +0 -0
  66. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/recovery_service.py +0 -0
  67. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/retry_service.py +0 -0
  68. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/snapshot_service.py +0 -0
  69. {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/submission_service.py +0 -0
  70. {syncfm_core-0.2.5 → 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.5
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
@@ -97,11 +97,11 @@ syncfm profile add librefm-main \
97
97
  --api-key YOUR_LIBREFM_API_KEY
98
98
  ```
99
99
 
100
- GNU.fm profiles also require the instance hostname:
100
+ LoneFM profiles also require the instance hostname:
101
101
 
102
102
  ```console
103
103
  syncfm profile add community \
104
- --type gnufm \
104
+ --type lonefm \
105
105
  --hostname music.example.org \
106
106
  --username alice \
107
107
  --api-key YOUR_API_KEY
@@ -236,6 +236,7 @@ The CLI is a frontend for the same public application services available to
236
236
  Python callers:
237
237
 
238
238
  ```python
239
+ from contextlib import ExitStack, closing
239
240
  from pathlib import Path
240
241
 
241
242
  from syncfm_core import (
@@ -248,17 +249,22 @@ from syncfm_core import (
248
249
  profiles = create_profile_service()
249
250
  factory = NetworkFactory()
250
251
 
251
- source = factory.create(profiles.resolve("lastfm-main"))
252
- target = factory.create(profiles.resolve("librefm-main"))
253
-
254
- service = create_sync_service(Path("syncfm.sqlite3"))
255
- result = service.sync(
256
- source,
257
- target,
258
- SyncRequest(start_timestamp=1704067200),
259
- source_profile="lastfm-main",
260
- target_profile="librefm-main",
261
- )
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
+ )
262
268
 
263
269
  print(f"Synchronized {result.synced_count} scrobbles")
264
270
  print(f"Failed: {result.failed_count}")
@@ -268,6 +274,21 @@ print(f"Failed: {result.failed_count}")
268
274
  The package also exports its domain models, network protocols, profile service,
269
275
  and structured exception hierarchy for custom frontends and integrations.
270
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
+
271
292
  ## Command reference
272
293
 
273
294
  ```text
@@ -291,7 +312,9 @@ Create the environment and run the project checks:
291
312
  ```console
292
313
  uv sync
293
314
  uv run ruff check .
315
+ uv run ruff format --check .
294
316
  uv run pyright
317
+ uv run pytest
295
318
  uv build
296
319
  ```
297
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
@@ -70,11 +70,11 @@ syncfm profile add librefm-main \
70
70
  --api-key YOUR_LIBREFM_API_KEY
71
71
  ```
72
72
 
73
- GNU.fm profiles also require the instance hostname:
73
+ LoneFM profiles also require the instance hostname:
74
74
 
75
75
  ```console
76
76
  syncfm profile add community \
77
- --type gnufm \
77
+ --type lonefm \
78
78
  --hostname music.example.org \
79
79
  --username alice \
80
80
  --api-key YOUR_API_KEY
@@ -209,6 +209,7 @@ The CLI is a frontend for the same public application services available to
209
209
  Python callers:
210
210
 
211
211
  ```python
212
+ from contextlib import ExitStack, closing
212
213
  from pathlib import Path
213
214
 
214
215
  from syncfm_core import (
@@ -221,17 +222,22 @@ from syncfm_core import (
221
222
  profiles = create_profile_service()
222
223
  factory = NetworkFactory()
223
224
 
224
- source = factory.create(profiles.resolve("lastfm-main"))
225
- target = factory.create(profiles.resolve("librefm-main"))
226
-
227
- service = create_sync_service(Path("syncfm.sqlite3"))
228
- result = service.sync(
229
- source,
230
- target,
231
- SyncRequest(start_timestamp=1704067200),
232
- source_profile="lastfm-main",
233
- target_profile="librefm-main",
234
- )
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
+ )
235
241
 
236
242
  print(f"Synchronized {result.synced_count} scrobbles")
237
243
  print(f"Failed: {result.failed_count}")
@@ -241,6 +247,21 @@ print(f"Failed: {result.failed_count}")
241
247
  The package also exports its domain models, network protocols, profile service,
242
248
  and structured exception hierarchy for custom frontends and integrations.
243
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
+
244
265
  ## Command reference
245
266
 
246
267
  ```text
@@ -264,7 +285,9 @@ Create the environment and run the project checks:
264
285
  ```console
265
286
  uv sync
266
287
  uv run ruff check .
288
+ uv run ruff format --check .
267
289
  uv run pyright
290
+ uv run pytest
268
291
  uv build
269
292
  ```
270
293
 
@@ -1,28 +1,28 @@
1
1
  [project]
2
2
  name = "syncfm-core"
3
- version = "0.2.5"
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.5"
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)
@@ -0,0 +1,9 @@
1
+ """Music-network integration provided by syncfm-core."""
2
+
3
+ from syncfm_core.networks.factory import NetworkFactory
4
+ from syncfm_core.networks.pyscrobbler_adapter import PyScrobblerNetworkAdapter
5
+
6
+ __all__ = [
7
+ "NetworkFactory",
8
+ "PyScrobblerNetworkAdapter",
9
+ ]