piperch 0.2.0__py3-none-any.whl

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 (114) hide show
  1. piperch/__init__.py +5 -0
  2. piperch/__main__.py +4 -0
  3. piperch/api/__init__.py +1 -0
  4. piperch/api/authors.py +66 -0
  5. piperch/api/converters.py +102 -0
  6. piperch/api/dependencies.py +7 -0
  7. piperch/api/discovery.py +216 -0
  8. piperch/api/downloads.py +107 -0
  9. piperch/api/favorites.py +82 -0
  10. piperch/api/gallery.py +278 -0
  11. piperch/api/groups.py +93 -0
  12. piperch/api/models.py +460 -0
  13. piperch/api/pixiv_images.py +37 -0
  14. piperch/api/settings.py +82 -0
  15. piperch/app.py +128 -0
  16. piperch/application/__init__.py +1 -0
  17. piperch/application/container.py +40 -0
  18. piperch/application/control.py +20 -0
  19. piperch/application/lifecycle.py +156 -0
  20. piperch/cli.py +262 -0
  21. piperch/database/__init__.py +3 -0
  22. piperch/database/connection.py +70 -0
  23. piperch/database/migrations/__init__.py +1 -0
  24. piperch/database/migrations/env.py +51 -0
  25. piperch/database/migrations/script.py.mako +25 -0
  26. piperch/database/migrations/versions/20260928_0001_initial.py +259 -0
  27. piperch/database/migrations/versions/20261003_0002_remove_download_page_progress.py +47 -0
  28. piperch/database/migrations/versions/20261004_0003_decouple_favorite_groups.py +69 -0
  29. piperch/database/migrations/versions/20261004_0004_add_tag_search_cache.py +39 -0
  30. piperch/database/migrations/versions/__init__.py +1 -0
  31. piperch/database/tables.py +224 -0
  32. piperch/domain/__init__.py +1 -0
  33. piperch/domain/artworks.py +110 -0
  34. piperch/domain/bookmarks.py +42 -0
  35. piperch/domain/discovery.py +55 -0
  36. piperch/domain/downloads.py +50 -0
  37. piperch/domain/favorites.py +26 -0
  38. piperch/domain/groups.py +49 -0
  39. piperch/errors.py +27 -0
  40. piperch/frontend/THIRD_PARTY_LICENSES.md +776 -0
  41. piperch/frontend/assets/ArtworkDetailPage-CfdHqJrT.css +1 -0
  42. piperch/frontend/assets/ArtworkDetailPage-CgoZ13R2.js +3 -0
  43. piperch/frontend/assets/AuthorIdButton.vue_vue_type_script_setup_true_lang-7TGqoQCH.js +1 -0
  44. piperch/frontend/assets/Card.vue_vue_type_script_setup_true_lang-CIXf0Bqh.js +1 -0
  45. piperch/frontend/assets/Dialog.vue_vue_type_script_setup_true_lang-Bwahx6wP.js +1 -0
  46. piperch/frontend/assets/DiscoverPage-D-ia9b0I.js +1 -0
  47. piperch/frontend/assets/DownloadPage-DfBVaueV.js +1 -0
  48. piperch/frontend/assets/GalleryPage-BOObPxx9.css +1 -0
  49. piperch/frontend/assets/GalleryPage-BiHujr-U.js +1 -0
  50. piperch/frontend/assets/GroupsPage-CAnzdqbq.js +1 -0
  51. piperch/frontend/assets/Input.vue_vue_type_script_setup_true_lang-D9SNKhyK.js +1 -0
  52. piperch/frontend/assets/Select.vue_vue_type_script_setup_true_lang-6gOpkZQ3.js +1 -0
  53. piperch/frontend/assets/SettingsPage-Ch2TfKua.js +1 -0
  54. piperch/frontend/assets/SettingsPopover.vue_vue_type_script_setup_true_lang-C52iIBBP.js +1 -0
  55. piperch/frontend/assets/Slider.vue_vue_type_script_setup_true_lang-BJiI9wzV.js +1 -0
  56. piperch/frontend/assets/Switch.vue_vue_type_script_setup_true_lang-CqQul2_l.js +1 -0
  57. piperch/frontend/assets/Tabs.vue_vue_type_script_setup_true_lang-BpdOFgMA.js +1 -0
  58. piperch/frontend/assets/Teleport-Bb0Z1ZOc.js +3 -0
  59. piperch/frontend/assets/Textarea.vue_vue_type_script_setup_true_lang-YBcCpHjh.js +1 -0
  60. piperch/frontend/assets/author-api-C7ExHl2y.js +1 -0
  61. piperch/frontend/assets/chevron-up-DtvuWhAU.js +1 -0
  62. piperch/frontend/assets/favorites-api-NBfBdO7e.js +1 -0
  63. piperch/frontend/assets/index-Dq9gCV56.css +1 -0
  64. piperch/frontend/assets/index-n4ewV_tT.js +59 -0
  65. piperch/frontend/assets/is-equal-BiYk8F2S.js +1 -0
  66. piperch/frontend/assets/refresh-cw-OTzfHyZn.js +1 -0
  67. piperch/frontend/assets/search-D4Z_-b6U.js +1 -0
  68. piperch/frontend/assets/useForwardScopeId-C7nrckeF.js +1 -0
  69. piperch/frontend/assets/useMarqueeSelection-CNGaBQ-5.js +1 -0
  70. piperch/frontend/assets/usePageKeyboardShortcuts-BYfZCsLJ.js +1 -0
  71. piperch/frontend/favicon.png +0 -0
  72. piperch/frontend/index.html +15 -0
  73. piperch/paths.py +36 -0
  74. piperch/pixiv/__init__.py +4 -0
  75. piperch/pixiv/client.py +799 -0
  76. piperch/pixiv/parsing.py +117 -0
  77. piperch/pixiv/transport.py +472 -0
  78. piperch/repositories/__init__.py +6 -0
  79. piperch/repositories/_rows.py +17 -0
  80. piperch/repositories/artworks.py +725 -0
  81. piperch/repositories/downloads.py +422 -0
  82. piperch/repositories/favorites.py +107 -0
  83. piperch/repositories/groups.py +205 -0
  84. piperch/resources/pixiv_tags.LICENSE +21 -0
  85. piperch/resources/pixiv_tags.SOURCE.json +8 -0
  86. piperch/resources/pixiv_tags.sqlite +0 -0
  87. piperch/services/__init__.py +1 -0
  88. piperch/services/downloads/__init__.py +15 -0
  89. piperch/services/downloads/artwork.py +437 -0
  90. piperch/services/downloads/cancellation.py +28 -0
  91. piperch/services/downloads/commit.py +74 -0
  92. piperch/services/downloads/supervisor.py +167 -0
  93. piperch/services/events.py +50 -0
  94. piperch/services/favorite_sync.py +57 -0
  95. piperch/services/library.py +151 -0
  96. piperch/services/library_commits.py +29 -0
  97. piperch/services/library_files.py +90 -0
  98. piperch/services/storage.py +381 -0
  99. piperch/services/tag_search.py +171 -0
  100. piperch/services/thumbnails.py +118 -0
  101. piperch/settings/__init__.py +4 -0
  102. piperch/settings/manager.py +95 -0
  103. piperch/settings/models.py +47 -0
  104. piperch/utils/__init__.py +1 -0
  105. piperch/utils/datetime.py +5 -0
  106. piperch/utils/text.py +7 -0
  107. piperch/utils/urls.py +34 -0
  108. piperch-0.2.0.dist-info/METADATA +66 -0
  109. piperch-0.2.0.dist-info/RECORD +114 -0
  110. piperch-0.2.0.dist-info/WHEEL +4 -0
  111. piperch-0.2.0.dist-info/entry_points.txt +2 -0
  112. piperch-0.2.0.dist-info/licenses/LICENSE +675 -0
  113. piperch-0.2.0.dist-info/licenses/src/piperch/frontend/THIRD_PARTY_LICENSES.md +776 -0
  114. piperch-0.2.0.dist-info/licenses/src/piperch/resources/pixiv_tags.LICENSE +21 -0
piperch/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from importlib.metadata import version
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = version("piperch")
piperch/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from piperch.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1 @@
1
+ __all__: list[str] = []
piperch/api/authors.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from anyio import to_thread
4
+ from fastapi import APIRouter, Depends, Path, Response, status
5
+
6
+ from piperch.api.dependencies import get_container
7
+ from piperch.api.models import (
8
+ FollowedUserResponse,
9
+ FollowedUsersResponse,
10
+ UserProfileResponse,
11
+ )
12
+ from piperch.api.pixiv_images import proxied_image_url
13
+ from piperch.application.container import AppContainer
14
+
15
+ router = APIRouter(prefix="/authors", tags=["authors"])
16
+
17
+
18
+ @router.get("/followed", response_model=FollowedUsersResponse)
19
+ async def followed_authors(
20
+ container: AppContainer = Depends(get_container),
21
+ ) -> FollowedUsersResponse:
22
+ settings = await to_thread.run_sync(container.settings.get)
23
+ items = await container.pixiv.list_followed_users(settings.pixiv_cookie)
24
+ return FollowedUsersResponse(
25
+ items=[
26
+ FollowedUserResponse(
27
+ user_id=item.user_id,
28
+ name=item.name,
29
+ avatar_url=proxied_image_url(item.avatar_url),
30
+ )
31
+ for item in items
32
+ ]
33
+ )
34
+
35
+
36
+ @router.get("/{author_id}/profile", response_model=UserProfileResponse)
37
+ async def author_profile(
38
+ author_id: int = Path(gt=0),
39
+ container: AppContainer = Depends(get_container),
40
+ ) -> UserProfileResponse:
41
+ settings = await to_thread.run_sync(container.settings.get)
42
+ profile = await container.pixiv.get_user_profile(author_id, settings.pixiv_cookie)
43
+ return UserProfileResponse(
44
+ avatar_url=proxied_image_url(profile.avatar_url),
45
+ is_followed=profile.is_followed,
46
+ )
47
+
48
+
49
+ @router.post("/{author_id}/follow", status_code=status.HTTP_204_NO_CONTENT)
50
+ async def follow_author(
51
+ author_id: int = Path(gt=0),
52
+ container: AppContainer = Depends(get_container),
53
+ ) -> Response:
54
+ settings = await to_thread.run_sync(container.settings.get)
55
+ await container.pixiv.follow_user(author_id, settings.pixiv_cookie)
56
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
57
+
58
+
59
+ @router.delete("/{author_id}/follow", status_code=status.HTTP_204_NO_CONTENT)
60
+ async def unfollow_author(
61
+ author_id: int = Path(gt=0),
62
+ container: AppContainer = Depends(get_container),
63
+ ) -> Response:
64
+ settings = await to_thread.run_sync(container.settings.get)
65
+ await container.pixiv.unfollow_user(author_id, settings.pixiv_cookie)
66
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from piperch.api.models import (
4
+ ArtworkDetailResponse,
5
+ ArtworkSummaryResponse,
6
+ DownloadItemResponse,
7
+ DownloadJobDetailResponse,
8
+ DownloadJobSummaryResponse,
9
+ JobCounts,
10
+ MediaResponse,
11
+ TagResponse,
12
+ UgoiraFrameResponse,
13
+ )
14
+ from piperch.domain.artworks import ArtworkDetail, ArtworkSummary
15
+ from piperch.domain.downloads import DownloadJobDetail, DownloadJobSummary, ItemState
16
+
17
+
18
+ def artwork_summary_response(summary: ArtworkSummary) -> ArtworkSummaryResponse:
19
+ return ArtworkSummaryResponse(
20
+ artwork_id=summary.artwork_id,
21
+ title=summary.title,
22
+ artwork_type=summary.artwork_type,
23
+ author_id=summary.author_id,
24
+ author_name=summary.author_name,
25
+ series_id=summary.series_id,
26
+ series_title=summary.series_title,
27
+ page_count=summary.page_count,
28
+ x_restrict=summary.x_restrict,
29
+ is_ai=summary.is_ai,
30
+ published_at=summary.published_at,
31
+ downloaded_at=summary.downloaded_at,
32
+ is_favorite=summary.is_favorite,
33
+ group_ids=list(summary.group_ids),
34
+ )
35
+
36
+
37
+ def artwork_detail_response(
38
+ detail: ArtworkDetail,
39
+ local_linked_artwork_ids: list[int],
40
+ ) -> ArtworkDetailResponse:
41
+ return ArtworkDetailResponse(
42
+ **artwork_summary_response(detail.summary).model_dump(),
43
+ description=detail.description,
44
+ local_linked_artwork_ids=local_linked_artwork_ids,
45
+ width=detail.width,
46
+ height=detail.height,
47
+ tags=[
48
+ TagResponse(
49
+ tag_id=tag.tag_id,
50
+ name=tag.name,
51
+ )
52
+ for tag in detail.tags
53
+ ],
54
+ media=[
55
+ MediaResponse(
56
+ role=item.role,
57
+ page_index=item.page_index,
58
+ mime_type=item.mime_type,
59
+ byte_size=item.byte_size,
60
+ )
61
+ for item in detail.media
62
+ ],
63
+ ugoira_frames=[
64
+ UgoiraFrameResponse(file_name=item.file_name, delay_ms=item.delay_ms) for item in detail.ugoira_frames
65
+ ],
66
+ )
67
+
68
+
69
+ def download_job_summary_response(job: DownloadJobSummary) -> DownloadJobSummaryResponse:
70
+ return DownloadJobSummaryResponse(
71
+ job_id=job.job_id,
72
+ source_label=job.source_label,
73
+ state=job.state,
74
+ created_at=job.created_at,
75
+ started_at=job.started_at,
76
+ finished_at=job.finished_at,
77
+ error_summary=job.error_summary,
78
+ counts=JobCounts(
79
+ queued=job.counts.get(ItemState.QUEUED, 0),
80
+ running=job.counts.get(ItemState.RUNNING, 0),
81
+ skipped=job.counts.get(ItemState.SKIPPED, 0),
82
+ succeeded=job.counts.get(ItemState.SUCCEEDED, 0),
83
+ failed=job.counts.get(ItemState.FAILED, 0),
84
+ cancelled=job.counts.get(ItemState.CANCELLED, 0),
85
+ ),
86
+ )
87
+
88
+
89
+ def download_job_detail_response(job: DownloadJobDetail) -> DownloadJobDetailResponse:
90
+ return DownloadJobDetailResponse(
91
+ **download_job_summary_response(job.summary).model_dump(),
92
+ items=[
93
+ DownloadItemResponse(
94
+ item_id=item.item_id,
95
+ artwork_id=item.artwork_id,
96
+ state=item.state,
97
+ attempts=item.attempts,
98
+ error=item.error,
99
+ )
100
+ for item in job.items
101
+ ],
102
+ )
@@ -0,0 +1,7 @@
1
+ from fastapi import Request
2
+
3
+ from piperch.application.container import AppContainer
4
+
5
+
6
+ def get_container(request: Request) -> AppContainer:
7
+ return request.app.state.container
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from anyio import to_thread
6
+ from fastapi import APIRouter, Depends, Path
7
+
8
+ from piperch.api.dependencies import get_container
9
+ from piperch.api.models import (
10
+ ArtworkDiscoveryFailureResponse,
11
+ ArtworkDiscoveryRequest,
12
+ ArtworkPreviewResponse,
13
+ BookmarkArtworkIdsRequest,
14
+ BookmarkDiscoveryRequest,
15
+ BookmarkFolderResponse,
16
+ BookmarkFoldersResponse,
17
+ DiscoveryItem,
18
+ DiscoveryRequest,
19
+ DiscoveryResponse,
20
+ FollowUpdatesDiscoveryRequest,
21
+ RecommendationsResponse,
22
+ RecommendedUserResponse,
23
+ RecommendedUsersResponse,
24
+ SearchDiscoveryRequest,
25
+ UserArtworkIdsResponse,
26
+ UserDiscoveryRequest,
27
+ )
28
+ from piperch.api.pixiv_images import proxied_image_url
29
+ from piperch.application.container import AppContainer
30
+ from piperch.domain.bookmarks import BookmarkFolderReference
31
+ from piperch.domain.discovery import DiscoveryCandidate
32
+
33
+ router = APIRouter(prefix="/discovery", tags=["discovery"])
34
+
35
+
36
+ def _discovery_items(
37
+ candidates: Sequence[DiscoveryCandidate],
38
+ existing_ids: set[int],
39
+ ) -> list[DiscoveryItem]:
40
+ return [
41
+ DiscoveryItem(
42
+ artwork_id=item.artwork_id,
43
+ title=item.title,
44
+ author_name=item.author_name,
45
+ artwork_type=item.artwork_type,
46
+ page_count=item.page_count,
47
+ x_restrict=item.x_restrict,
48
+ is_ai=item.is_ai,
49
+ thumbnail_url=proxied_image_url(item.thumbnail_url),
50
+ in_library=item.artwork_id in existing_ids,
51
+ )
52
+ for item in candidates
53
+ ]
54
+
55
+
56
+ @router.post("", response_model=DiscoveryResponse)
57
+ async def discover(
58
+ request: DiscoveryRequest,
59
+ container: AppContainer = Depends(get_container),
60
+ ) -> DiscoveryResponse:
61
+ settings = await to_thread.run_sync(container.settings.get)
62
+ next_page: int | None = None
63
+ failures: list[ArtworkDiscoveryFailureResponse] = []
64
+ if isinstance(request, ArtworkDiscoveryRequest):
65
+ result = await container.pixiv.discover_artworks(
66
+ request.inputs,
67
+ request.page,
68
+ settings.pixiv_cookie,
69
+ )
70
+ candidates, next_page = result.candidates, result.next_page
71
+ failures = [
72
+ ArtworkDiscoveryFailureResponse(artwork_id=item.artwork_id, message=item.message)
73
+ for item in result.failures
74
+ ]
75
+ elif isinstance(request, SearchDiscoveryRequest):
76
+ candidates, next_page = await container.pixiv.discover_search(
77
+ request.keyword,
78
+ request.page,
79
+ settings.pixiv_cookie,
80
+ )
81
+ elif isinstance(request, UserDiscoveryRequest):
82
+ candidates, next_page = await container.pixiv.discover_user(
83
+ request.user_id,
84
+ request.page,
85
+ settings.pixiv_cookie,
86
+ )
87
+ elif isinstance(request, BookmarkDiscoveryRequest):
88
+ candidates, next_page = await container.pixiv.discover_bookmarks(
89
+ BookmarkFolderReference(request.folder.visibility, request.folder.tag),
90
+ request.page,
91
+ settings.pixiv_cookie,
92
+ )
93
+ elif isinstance(request, FollowUpdatesDiscoveryRequest):
94
+ candidates, next_page = await container.pixiv.discover_follow_updates(
95
+ request.page,
96
+ settings.pixiv_cookie,
97
+ )
98
+ else:
99
+ candidates, next_page = await container.pixiv.discover_series(
100
+ request.series_id,
101
+ request.page,
102
+ settings.pixiv_cookie,
103
+ )
104
+ existing_ids = await to_thread.run_sync(
105
+ container.artworks.find_existing_ids,
106
+ [item.artwork_id for item in candidates],
107
+ )
108
+ return DiscoveryResponse(
109
+ items=_discovery_items(candidates, existing_ids),
110
+ page=request.page,
111
+ next_page=next_page,
112
+ failures=failures,
113
+ )
114
+
115
+
116
+ @router.get("/recommendations", response_model=RecommendationsResponse)
117
+ async def recommendations(
118
+ container: AppContainer = Depends(get_container),
119
+ ) -> RecommendationsResponse:
120
+ settings = await to_thread.run_sync(container.settings.get)
121
+ candidates = await container.pixiv.discover_recommended_artworks(settings.pixiv_cookie)
122
+ existing_ids = await to_thread.run_sync(
123
+ container.artworks.find_existing_ids,
124
+ [item.artwork_id for item in candidates],
125
+ )
126
+ return RecommendationsResponse(items=_discovery_items(candidates, existing_ids))
127
+
128
+
129
+ @router.get("/recommended-users", response_model=RecommendedUsersResponse)
130
+ async def recommended_users(
131
+ container: AppContainer = Depends(get_container),
132
+ ) -> RecommendedUsersResponse:
133
+ settings = await to_thread.run_sync(container.settings.get)
134
+ users = await container.pixiv.discover_recommended_users(settings.pixiv_cookie)
135
+ existing_ids = await to_thread.run_sync(
136
+ container.artworks.find_existing_ids,
137
+ [artwork.artwork_id for user in users for artwork in user.artworks],
138
+ )
139
+ return RecommendedUsersResponse(
140
+ items=[
141
+ RecommendedUserResponse(
142
+ user_id=user.user_id,
143
+ name=user.name,
144
+ comment=user.comment,
145
+ avatar_url=proxied_image_url(user.avatar_url),
146
+ is_followed=user.is_followed,
147
+ artworks=_discovery_items(user.artworks, existing_ids),
148
+ )
149
+ for user in users
150
+ ]
151
+ )
152
+
153
+
154
+ @router.get(
155
+ "/artworks/{artwork_id}/preview",
156
+ response_model=ArtworkPreviewResponse,
157
+ )
158
+ async def artwork_preview(
159
+ artwork_id: int = Path(gt=0),
160
+ container: AppContainer = Depends(get_container),
161
+ ) -> ArtworkPreviewResponse:
162
+ settings = await to_thread.run_sync(container.settings.get)
163
+ urls = await container.pixiv.list_artwork_preview_urls(
164
+ artwork_id,
165
+ settings.pixiv_cookie,
166
+ )
167
+ return ArtworkPreviewResponse(urls=[url for item in urls if (url := proxied_image_url(item)) is not None])
168
+
169
+
170
+ @router.get("/bookmark-folders", response_model=BookmarkFoldersResponse)
171
+ async def bookmark_folders(
172
+ container: AppContainer = Depends(get_container),
173
+ ) -> BookmarkFoldersResponse:
174
+ settings = await to_thread.run_sync(container.settings.get)
175
+ folders = await container.pixiv.list_bookmark_folders(settings.pixiv_cookie)
176
+ return BookmarkFoldersResponse(
177
+ items=[
178
+ BookmarkFolderResponse(
179
+ visibility=folder.reference.visibility,
180
+ tag=folder.reference.tag,
181
+ kind=folder.kind,
182
+ name=folder.name,
183
+ item_count=folder.item_count,
184
+ )
185
+ for folder in folders
186
+ ]
187
+ )
188
+
189
+
190
+ @router.post("/bookmarks/selectable-artwork-ids", response_model=UserArtworkIdsResponse)
191
+ async def selectable_bookmark_artwork_ids(
192
+ request: BookmarkArtworkIdsRequest,
193
+ container: AppContainer = Depends(get_container),
194
+ ) -> UserArtworkIdsResponse:
195
+ settings = await to_thread.run_sync(container.settings.get)
196
+ artwork_ids = await container.pixiv.list_bookmark_artwork_ids(
197
+ [BookmarkFolderReference(folder.visibility, folder.tag) for folder in request.folders],
198
+ settings.pixiv_cookie,
199
+ )
200
+ existing_ids = await to_thread.run_sync(container.artworks.find_existing_ids, artwork_ids)
201
+ return UserArtworkIdsResponse(
202
+ artwork_ids=[artwork_id for artwork_id in artwork_ids if artwork_id not in existing_ids]
203
+ )
204
+
205
+
206
+ @router.get("/users/{user_id}/selectable-artwork-ids", response_model=UserArtworkIdsResponse)
207
+ async def selectable_user_artwork_ids(
208
+ user_id: int = Path(gt=0),
209
+ container: AppContainer = Depends(get_container),
210
+ ) -> UserArtworkIdsResponse:
211
+ settings = await to_thread.run_sync(container.settings.get)
212
+ artwork_ids = await container.pixiv.list_user_artwork_ids(user_id, settings.pixiv_cookie)
213
+ existing_ids = await to_thread.run_sync(container.artworks.find_existing_ids, artwork_ids)
214
+ return UserArtworkIdsResponse(
215
+ artwork_ids=[artwork_id for artwork_id in artwork_ids if artwork_id not in existing_ids]
216
+ )
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import math
6
+ from collections.abc import AsyncIterator
7
+
8
+ from anyio import to_thread
9
+ from fastapi import APIRouter, Depends, Query, Request, status
10
+ from fastapi.responses import StreamingResponse
11
+
12
+ from piperch.api.converters import download_job_detail_response, download_job_summary_response
13
+ from piperch.api.dependencies import get_container
14
+ from piperch.api.models import (
15
+ DownloadJobCreate,
16
+ DownloadJobCreated,
17
+ DownloadJobDetailResponse,
18
+ DownloadJobPage,
19
+ )
20
+ from piperch.application.container import AppContainer
21
+
22
+ router = APIRouter(prefix="/download-jobs", tags=["downloads"])
23
+ events_router = APIRouter(prefix="/events", tags=["downloads"])
24
+
25
+
26
+ @router.post("", response_model=DownloadJobCreated, status_code=status.HTTP_202_ACCEPTED)
27
+ async def create_job(
28
+ request: DownloadJobCreate,
29
+ container: AppContainer = Depends(get_container),
30
+ ) -> DownloadJobCreated:
31
+ container.storage.require_available()
32
+ job_id = await to_thread.run_sync(
33
+ container.downloads.create_job,
34
+ request.artwork_ids,
35
+ request.source_label,
36
+ )
37
+ container.supervisor.notify()
38
+ await container.events.publish_job_update(job_id)
39
+ return DownloadJobCreated(job_id=job_id)
40
+
41
+
42
+ @router.get("", response_model=DownloadJobPage)
43
+ def list_jobs(
44
+ page: int = Query(default=0, ge=0),
45
+ size: int = Query(default=20, ge=1, le=100),
46
+ container: AppContainer = Depends(get_container),
47
+ ) -> DownloadJobPage:
48
+ jobs, total = container.downloads.list_jobs(page, size)
49
+ return DownloadJobPage(
50
+ items=[download_job_summary_response(job) for job in jobs],
51
+ page=page,
52
+ size=size,
53
+ total_elements=total,
54
+ total_pages=math.ceil(total / size),
55
+ )
56
+
57
+
58
+ @router.get("/{job_id}", response_model=DownloadJobDetailResponse)
59
+ def get_job(
60
+ job_id: str,
61
+ container: AppContainer = Depends(get_container),
62
+ ) -> DownloadJobDetailResponse:
63
+ return download_job_detail_response(container.downloads.get_job(job_id))
64
+
65
+
66
+ @router.post("/{job_id}/cancel", status_code=status.HTTP_202_ACCEPTED)
67
+ async def cancel_job(
68
+ job_id: str,
69
+ container: AppContainer = Depends(get_container),
70
+ ) -> None:
71
+ await container.supervisor.request_cancel(job_id)
72
+
73
+
74
+ @router.post("/{job_id}/retry", status_code=status.HTTP_202_ACCEPTED)
75
+ async def retry_job(
76
+ job_id: str,
77
+ container: AppContainer = Depends(get_container),
78
+ ) -> None:
79
+ container.storage.require_available()
80
+ await to_thread.run_sync(container.downloads.retry, job_id)
81
+ container.supervisor.notify()
82
+ await container.events.publish_job_update(job_id)
83
+
84
+
85
+ async def _event_stream(request: Request, container: AppContainer) -> AsyncIterator[str]:
86
+ yield "retry: 2000\n\n"
87
+ async with container.events.subscribe() as queue:
88
+ while not await request.is_disconnected():
89
+ try:
90
+ event = await asyncio.wait_for(queue.get(), timeout=15)
91
+ except TimeoutError:
92
+ yield ": heartbeat\n\n"
93
+ continue
94
+ data = json.dumps(event.data)
95
+ yield f"event: {event.name}\ndata: {data}\n\n"
96
+
97
+
98
+ @events_router.get("/downloads", response_class=StreamingResponse)
99
+ async def download_events(
100
+ request: Request,
101
+ container: AppContainer = Depends(get_container),
102
+ ) -> StreamingResponse:
103
+ return StreamingResponse(
104
+ _event_stream(request, container),
105
+ media_type="text/event-stream",
106
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
107
+ )
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from uuid import UUID
4
+
5
+ from anyio import to_thread
6
+ from fastapi import APIRouter, Depends
7
+ from fastapi import Path as PathParameter
8
+
9
+ from piperch.api.dependencies import get_container
10
+ from piperch.api.models import (
11
+ FavoriteStateRequest,
12
+ FavoriteStateResponse,
13
+ FavoriteSyncPlanResponse,
14
+ FavoriteSyncResultResponse,
15
+ )
16
+ from piperch.application.container import AppContainer
17
+ from piperch.domain.bookmarks import BookmarkFolderReference, BookmarkVisibility
18
+
19
+ router = APIRouter(tags=["favorites"])
20
+
21
+
22
+ @router.post("/favorite-sync-plans", response_model=FavoriteSyncPlanResponse)
23
+ async def create_favorite_sync_plan(
24
+ container: AppContainer = Depends(get_container),
25
+ ) -> FavoriteSyncPlanResponse:
26
+ settings = await to_thread.run_sync(container.settings.get)
27
+ artwork_ids = await container.pixiv.list_bookmark_artwork_ids(
28
+ [
29
+ BookmarkFolderReference(BookmarkVisibility.PUBLIC, None),
30
+ BookmarkFolderReference(BookmarkVisibility.PRIVATE, None),
31
+ ],
32
+ settings.pixiv_cookie,
33
+ )
34
+ plan = await to_thread.run_sync(lambda: container.favorite_sync.create_plan(artwork_ids))
35
+ diff = plan.diff
36
+ return FavoriteSyncPlanResponse(
37
+ plan_id=str(plan.plan_id),
38
+ pixiv_favorite_count=diff.pixiv_favorite_count,
39
+ local_artwork_count=diff.local_artwork_count,
40
+ local_favorite_count=diff.local_favorite_count,
41
+ matched_favorite_count=diff.matched_favorite_count,
42
+ unavailable_locally_count=diff.unavailable_locally_count,
43
+ add_count=len(diff.add_artwork_ids),
44
+ remove_count=len(diff.remove_artwork_ids),
45
+ )
46
+
47
+
48
+ @router.post(
49
+ "/favorite-sync-plans/{plan_id}/apply",
50
+ response_model=FavoriteSyncResultResponse,
51
+ )
52
+ async def apply_favorite_sync_plan(
53
+ plan_id: UUID = PathParameter(),
54
+ container: AppContainer = Depends(get_container),
55
+ ) -> FavoriteSyncResultResponse:
56
+ result = await to_thread.run_sync(lambda: container.favorite_sync.apply_plan(plan_id))
57
+ return FavoriteSyncResultResponse(added=result.added, removed=result.removed)
58
+
59
+
60
+ @router.put("/artworks/{artwork_id}/favorite", response_model=FavoriteStateResponse)
61
+ async def replace_favorite_state(
62
+ request: FavoriteStateRequest,
63
+ artwork_id: int = PathParameter(gt=0),
64
+ container: AppContainer = Depends(get_container),
65
+ ) -> FavoriteStateResponse:
66
+ await to_thread.run_sync(container.favorites.get_state, artwork_id)
67
+ settings = await to_thread.run_sync(container.settings.get)
68
+ await container.pixiv.sync_bookmark_state(
69
+ artwork_id,
70
+ request.is_favorite,
71
+ settings.pixiv_cookie,
72
+ )
73
+ state = await to_thread.run_sync(
74
+ lambda: container.favorites.replace_state(
75
+ artwork_id,
76
+ is_favorite=request.is_favorite,
77
+ )
78
+ )
79
+ return FavoriteStateResponse(
80
+ artwork_id=state.artwork_id,
81
+ is_favorite=state.is_favorite,
82
+ )