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.
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/PKG-INFO +45 -22
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/README.md +39 -16
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/pyproject.toml +9 -8
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/pyproject.toml.orig +9 -8
- {syncfm_core-0.2.5 → 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.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/profile.py +1 -1
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/resume.py +13 -14
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/sync.py +17 -18
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/context.py +6 -2
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/verification.py +13 -14
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/network_config.py +12 -3
- {syncfm_core-0.2.5 → 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.5 → 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.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/models.py +3 -3
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/database.py +13 -2
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/queue_repository.py +4 -1
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/schema.py +3 -1
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/snapshot_repository.py +8 -3
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/sync_service.py +16 -3
- syncfm_core-0.2.5/src/syncfm_core/cli/commands/common.py +0 -45
- syncfm_core-0.2.5/src/syncfm_core/networks/__init__.py +0 -13
- syncfm_core-0.2.5/src/syncfm_core/networks/gnufm.py +0 -93
- syncfm_core-0.2.5/src/syncfm_core/networks/pylast_adapter.py +0 -310
- syncfm_core-0.2.5/src/syncfm_core/networks/pylast_mapper.py +0 -76
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/LICENSE +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/__init__.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/application.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/__init__.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/__init__.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/discard.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/commands/status.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/configuration.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/dashboard.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/cli/presenters.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/exceptions.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/__init__.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/retry_policy.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_progress.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_queue_item.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_request.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_result.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_session.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_status.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/models/sync_verification.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/__init__.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/factory.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/keyring_store.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/paths.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/protocols.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/resolver.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/service.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/profiles/toml_store.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/protocols/__init__.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/protocols/network.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/protocols/repositories.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/py.typed +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/__init__.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/repositories/session_repository.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/__init__.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/comparison_service.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/metadata_service.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/range_resolver.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/recovery_service.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/retry_service.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/snapshot_service.py +0 -0
- {syncfm_core-0.2.5 → syncfm_core-0.3.0}/src/syncfm_core/services/submission_service.py +0 -0
- {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.
|
|
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
|
|
@@ -97,11 +97,11 @@ syncfm profile add librefm-main \
|
|
|
97
97
|
--api-key YOUR_LIBREFM_API_KEY
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
LoneFM profiles also require the instance hostname:
|
|
101
101
|
|
|
102
102
|
```console
|
|
103
103
|
syncfm profile add community \
|
|
104
|
-
--type
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
@@ -70,11 +70,11 @@ syncfm profile add librefm-main \
|
|
|
70
70
|
--api-key YOUR_LIBREFM_API_KEY
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
LoneFM profiles also require the instance hostname:
|
|
74
74
|
|
|
75
75
|
```console
|
|
76
76
|
syncfm profile add community \
|
|
77
|
-
--type
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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.
|
|
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)
|
|
@@ -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
|
+
]
|