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.
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/PKG-INFO +46 -25
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/README.md +40 -19
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/pyproject.toml +9 -8
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/pyproject.toml.orig +9 -8
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/app.py +1 -1
- syncfm_core-0.3.0/src/syncfm_core/cli/commands/common.py +60 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/profile.py +1 -1
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/resume.py +13 -14
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/sync.py +17 -18
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/context.py +6 -2
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/verification.py +13 -14
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/network_config.py +12 -3
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/scrobble.py +2 -1
- syncfm_core-0.3.0/src/syncfm_core/networks/__init__.py +9 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/networks/factory.py +47 -26
- syncfm_core-0.3.0/src/syncfm_core/networks/pyscrobbler_adapter.py +176 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/models.py +6 -6
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/database.py +13 -2
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/queue_repository.py +4 -1
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/schema.py +3 -1
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/snapshot_repository.py +8 -3
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/sync_service.py +16 -3
- syncfm_core-0.2.4/src/syncfm_core/cli/commands/common.py +0 -45
- syncfm_core-0.2.4/src/syncfm_core/networks/__init__.py +0 -13
- syncfm_core-0.2.4/src/syncfm_core/networks/gnufm.py +0 -93
- syncfm_core-0.2.4/src/syncfm_core/networks/pylast_adapter.py +0 -310
- syncfm_core-0.2.4/src/syncfm_core/networks/pylast_mapper.py +0 -76
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/LICENSE +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/__init__.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/application.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/__init__.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/__init__.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/discard.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/status.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/configuration.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/dashboard.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/cli/presenters.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/exceptions.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/__init__.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/retry_policy.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_progress.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_queue_item.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_request.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_result.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_session.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_status.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_verification.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/__init__.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/factory.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/keyring_store.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/paths.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/protocols.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/resolver.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/service.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/profiles/toml_store.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/protocols/__init__.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/protocols/network.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/protocols/repositories.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/py.typed +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/__init__.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/repositories/session_repository.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/__init__.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/comparison_service.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/metadata_service.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/range_resolver.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/recovery_service.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/retry_service.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/snapshot_service.py +0 -0
- {syncfm_core-0.2.4 → syncfm_core-0.3.0}/src/syncfm_core/services/submission_service.py +0 -0
- {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.
|
|
4
|
-
Summary: Reusable synchronization engine and CLI for Last.fm, Libre.fm and
|
|
5
|
-
Keywords: lastfm,librefm,
|
|
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
|
+
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:
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
100
|
+
LoneFM profiles also require the instance hostname:
|
|
103
101
|
|
|
104
102
|
```console
|
|
105
103
|
syncfm profile add community \
|
|
106
|
-
--type
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
73
|
+
LoneFM profiles also require the instance hostname:
|
|
76
74
|
|
|
77
75
|
```console
|
|
78
76
|
syncfm profile add community \
|
|
79
|
-
--type
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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.
|
|
4
|
-
description = "Reusable synchronization engine and CLI for Last.fm, Libre.fm and
|
|
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
|
-
"
|
|
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.
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
19
|
"Operating System :: OS Independent",
|
|
20
20
|
]
|
|
21
|
-
requires-python = ">=3.
|
|
21
|
+
requires-python = ">=3.13"
|
|
22
22
|
dependencies = [
|
|
23
23
|
"keyring>=25.7.0",
|
|
24
24
|
"platformdirs>=4.11.1",
|
|
25
|
-
"
|
|
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.
|
|
57
|
+
pythonVersion = "3.13"
|
|
57
58
|
typeCheckingMode = "strict"
|
|
58
59
|
venvPath = "."
|
|
59
60
|
venv = ".venv"
|
|
60
61
|
|
|
61
62
|
[tool.ruff]
|
|
62
|
-
target-version = "
|
|
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.
|
|
4
|
-
description = "Reusable synchronization engine and CLI for Last.fm, Libre.fm and
|
|
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", "
|
|
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.
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
16
|
"Operating System :: OS Independent",
|
|
17
17
|
]
|
|
18
|
-
requires-python = ">=3.
|
|
18
|
+
requires-python = ">=3.13"
|
|
19
19
|
dependencies = [
|
|
20
20
|
"keyring>=25.7.0",
|
|
21
21
|
"platformdirs>=4.11.1",
|
|
22
|
-
"
|
|
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.
|
|
50
|
+
pythonVersion = "3.13"
|
|
50
51
|
typeCheckingMode = "strict"
|
|
51
52
|
venvPath = "."
|
|
52
53
|
venv = ".venv"
|
|
53
54
|
|
|
54
55
|
[tool.ruff]
|
|
55
|
-
target-version = "
|
|
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
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
84
|
+
with create_network_pair(
|
|
85
85
|
cli_context,
|
|
86
86
|
source_profile,
|
|
87
87
|
target_profile,
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
47
|
+
"""Execute an operation, restoring progress output before reporting errors."""
|
|
45
48
|
try:
|
|
46
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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.
|
|
23
|
-
return "
|
|
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)
|