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.
- piperch/__init__.py +5 -0
- piperch/__main__.py +4 -0
- piperch/api/__init__.py +1 -0
- piperch/api/authors.py +66 -0
- piperch/api/converters.py +102 -0
- piperch/api/dependencies.py +7 -0
- piperch/api/discovery.py +216 -0
- piperch/api/downloads.py +107 -0
- piperch/api/favorites.py +82 -0
- piperch/api/gallery.py +278 -0
- piperch/api/groups.py +93 -0
- piperch/api/models.py +460 -0
- piperch/api/pixiv_images.py +37 -0
- piperch/api/settings.py +82 -0
- piperch/app.py +128 -0
- piperch/application/__init__.py +1 -0
- piperch/application/container.py +40 -0
- piperch/application/control.py +20 -0
- piperch/application/lifecycle.py +156 -0
- piperch/cli.py +262 -0
- piperch/database/__init__.py +3 -0
- piperch/database/connection.py +70 -0
- piperch/database/migrations/__init__.py +1 -0
- piperch/database/migrations/env.py +51 -0
- piperch/database/migrations/script.py.mako +25 -0
- piperch/database/migrations/versions/20260928_0001_initial.py +259 -0
- piperch/database/migrations/versions/20261003_0002_remove_download_page_progress.py +47 -0
- piperch/database/migrations/versions/20261004_0003_decouple_favorite_groups.py +69 -0
- piperch/database/migrations/versions/20261004_0004_add_tag_search_cache.py +39 -0
- piperch/database/migrations/versions/__init__.py +1 -0
- piperch/database/tables.py +224 -0
- piperch/domain/__init__.py +1 -0
- piperch/domain/artworks.py +110 -0
- piperch/domain/bookmarks.py +42 -0
- piperch/domain/discovery.py +55 -0
- piperch/domain/downloads.py +50 -0
- piperch/domain/favorites.py +26 -0
- piperch/domain/groups.py +49 -0
- piperch/errors.py +27 -0
- piperch/frontend/THIRD_PARTY_LICENSES.md +776 -0
- piperch/frontend/assets/ArtworkDetailPage-CfdHqJrT.css +1 -0
- piperch/frontend/assets/ArtworkDetailPage-CgoZ13R2.js +3 -0
- piperch/frontend/assets/AuthorIdButton.vue_vue_type_script_setup_true_lang-7TGqoQCH.js +1 -0
- piperch/frontend/assets/Card.vue_vue_type_script_setup_true_lang-CIXf0Bqh.js +1 -0
- piperch/frontend/assets/Dialog.vue_vue_type_script_setup_true_lang-Bwahx6wP.js +1 -0
- piperch/frontend/assets/DiscoverPage-D-ia9b0I.js +1 -0
- piperch/frontend/assets/DownloadPage-DfBVaueV.js +1 -0
- piperch/frontend/assets/GalleryPage-BOObPxx9.css +1 -0
- piperch/frontend/assets/GalleryPage-BiHujr-U.js +1 -0
- piperch/frontend/assets/GroupsPage-CAnzdqbq.js +1 -0
- piperch/frontend/assets/Input.vue_vue_type_script_setup_true_lang-D9SNKhyK.js +1 -0
- piperch/frontend/assets/Select.vue_vue_type_script_setup_true_lang-6gOpkZQ3.js +1 -0
- piperch/frontend/assets/SettingsPage-Ch2TfKua.js +1 -0
- piperch/frontend/assets/SettingsPopover.vue_vue_type_script_setup_true_lang-C52iIBBP.js +1 -0
- piperch/frontend/assets/Slider.vue_vue_type_script_setup_true_lang-BJiI9wzV.js +1 -0
- piperch/frontend/assets/Switch.vue_vue_type_script_setup_true_lang-CqQul2_l.js +1 -0
- piperch/frontend/assets/Tabs.vue_vue_type_script_setup_true_lang-BpdOFgMA.js +1 -0
- piperch/frontend/assets/Teleport-Bb0Z1ZOc.js +3 -0
- piperch/frontend/assets/Textarea.vue_vue_type_script_setup_true_lang-YBcCpHjh.js +1 -0
- piperch/frontend/assets/author-api-C7ExHl2y.js +1 -0
- piperch/frontend/assets/chevron-up-DtvuWhAU.js +1 -0
- piperch/frontend/assets/favorites-api-NBfBdO7e.js +1 -0
- piperch/frontend/assets/index-Dq9gCV56.css +1 -0
- piperch/frontend/assets/index-n4ewV_tT.js +59 -0
- piperch/frontend/assets/is-equal-BiYk8F2S.js +1 -0
- piperch/frontend/assets/refresh-cw-OTzfHyZn.js +1 -0
- piperch/frontend/assets/search-D4Z_-b6U.js +1 -0
- piperch/frontend/assets/useForwardScopeId-C7nrckeF.js +1 -0
- piperch/frontend/assets/useMarqueeSelection-CNGaBQ-5.js +1 -0
- piperch/frontend/assets/usePageKeyboardShortcuts-BYfZCsLJ.js +1 -0
- piperch/frontend/favicon.png +0 -0
- piperch/frontend/index.html +15 -0
- piperch/paths.py +36 -0
- piperch/pixiv/__init__.py +4 -0
- piperch/pixiv/client.py +799 -0
- piperch/pixiv/parsing.py +117 -0
- piperch/pixiv/transport.py +472 -0
- piperch/repositories/__init__.py +6 -0
- piperch/repositories/_rows.py +17 -0
- piperch/repositories/artworks.py +725 -0
- piperch/repositories/downloads.py +422 -0
- piperch/repositories/favorites.py +107 -0
- piperch/repositories/groups.py +205 -0
- piperch/resources/pixiv_tags.LICENSE +21 -0
- piperch/resources/pixiv_tags.SOURCE.json +8 -0
- piperch/resources/pixiv_tags.sqlite +0 -0
- piperch/services/__init__.py +1 -0
- piperch/services/downloads/__init__.py +15 -0
- piperch/services/downloads/artwork.py +437 -0
- piperch/services/downloads/cancellation.py +28 -0
- piperch/services/downloads/commit.py +74 -0
- piperch/services/downloads/supervisor.py +167 -0
- piperch/services/events.py +50 -0
- piperch/services/favorite_sync.py +57 -0
- piperch/services/library.py +151 -0
- piperch/services/library_commits.py +29 -0
- piperch/services/library_files.py +90 -0
- piperch/services/storage.py +381 -0
- piperch/services/tag_search.py +171 -0
- piperch/services/thumbnails.py +118 -0
- piperch/settings/__init__.py +4 -0
- piperch/settings/manager.py +95 -0
- piperch/settings/models.py +47 -0
- piperch/utils/__init__.py +1 -0
- piperch/utils/datetime.py +5 -0
- piperch/utils/text.py +7 -0
- piperch/utils/urls.py +34 -0
- piperch-0.2.0.dist-info/METADATA +66 -0
- piperch-0.2.0.dist-info/RECORD +114 -0
- piperch-0.2.0.dist-info/WHEEL +4 -0
- piperch-0.2.0.dist-info/entry_points.txt +2 -0
- piperch-0.2.0.dist-info/licenses/LICENSE +675 -0
- piperch-0.2.0.dist-info/licenses/src/piperch/frontend/THIRD_PARTY_LICENSES.md +776 -0
- piperch-0.2.0.dist-info/licenses/src/piperch/resources/pixiv_tags.LICENSE +21 -0
piperch/__init__.py
ADDED
piperch/__main__.py
ADDED
piperch/api/__init__.py
ADDED
|
@@ -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
|
+
)
|
piperch/api/discovery.py
ADDED
|
@@ -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
|
+
)
|
piperch/api/downloads.py
ADDED
|
@@ -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
|
+
)
|
piperch/api/favorites.py
ADDED
|
@@ -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
|
+
)
|