207 Commits

Author SHA1 Message Date
Yuvi9587
9db89cfad0 Commit 2025-07-25 11:00:33 -07:00
Yuvi9587
0a6034a632 Update features.md 2025-07-25 10:49:51 -07:00
Yuvi9587
2da69e7017 Update features.md 2025-07-25 10:45:50 -07:00
Yuvi9587
3209770d00 Update LICENSE 2025-07-23 20:14:01 -07:00
Yuvi9587
337cdd342c Commit 2025-07-23 20:08:44 -07:00
Yuvi9587
d54b013bbc commit 2025-07-22 07:00:34 -07:00
Yuvi9587
2785fc1121 Update EmptyPopupDialog.py 2025-07-19 20:27:55 -07:00
Yuvi9587
fbdae61b80 Commit 2025-07-19 03:28:32 -07:00
Yuvi9587
33133eb275 Update assets.py 2025-07-18 08:28:58 -07:00
Yuvi9587
3935cbeea4 Commit 2025-07-18 07:54:11 -07:00
Yuvi9587
8ba2a572fa Update readme.md 2025-07-16 09:51:04 -07:00
Yuvi9587
8db40f03b6 Update readme.md 2025-07-16 09:50:41 -07:00
Yuvi9587
742fe7685c Update readme.md 2025-07-16 09:49:47 -07:00
Yuvi9587
e085d9a134 Update readme.md 2025-07-16 09:49:05 -07:00
Yuvi9587
1cd03731c0 Update readme.md 2025-07-16 09:47:51 -07:00
Yuvi9587
0bc8d7c692 Update readme.md 2025-07-16 09:47:07 -07:00
Yuvi9587
3a9009e76e Update readme.md 2025-07-16 09:45:40 -07:00
Yuvi9587
9a28e922b4 Commit 2025-07-16 09:42:52 -07:00
Yuvi9587
923a0ff61e Update readme.md 2025-07-16 09:41:37 -07:00
Yuvi9587
e891a2a845 Update readme.md 2025-07-16 09:41:18 -07:00
Yuvi9587
778b0219e2 Update readme.md 2025-07-16 09:39:58 -07:00
Yuvi9587
3fc08d9ea7 Commit 2025-07-16 09:39:07 -07:00
Yuvi9587
af6a6add57 Update readme.md 2025-07-16 09:35:30 -07:00
Yuvi9587
7737d32ef9 Update readme.md 2025-07-16 09:34:22 -07:00
Yuvi9587
c08cbb6490 Update readme.md 2025-07-16 09:30:43 -07:00
Yuvi9587
92a2e91624 Update readme.md 2025-07-16 09:29:46 -07:00
Yuvi9587
11ea511a9d Update readme.md 2025-07-16 09:28:48 -07:00
Yuvi9587
8abdb49ed8 Update readme.md 2025-07-16 09:27:51 -07:00
Yuvi9587
0873dd1ce0 Update readme.md 2025-07-16 09:27:26 -07:00
Yuvi9587
df5fbc1f73 Update readme.md 2025-07-16 09:25:51 -07:00
Yuvi9587
5510f7f0c6 Update readme.md 2025-07-16 09:25:29 -07:00
Yuvi9587
2f0593c450 Update readme.md 2025-07-16 09:23:27 -07:00
Yuvi9587
e67adb6bdc Update readme.md 2025-07-16 09:23:02 -07:00
Yuvi9587
d39081088c Update FUNDING.yml 2025-07-16 09:21:06 -07:00
Yuvi9587
f303b8b020 Commit 2025-07-16 09:02:47 -07:00
Yuvi9587
539e76aa9e Delete workers.py 2025-07-15 21:09:16 -07:00
Yuvi9587
574d0d66b4 Commit 2025-07-15 21:08:11 -07:00
Yuvi9587
9e58a9d574 commit 2025-07-15 08:49:20 -07:00
Yuvi9587
d67de87a11 Commit 2025-07-15 07:14:40 -07:00
Yuvi9587
149f217f2f Commit 2025-07-15 07:05:36 -07:00
Yuvi9587
874902ad60 Commit 2025-07-15 06:54:31 -07:00
Yuvi9587
440cf60d90 Update MoreOptionsDialog.py 2025-07-14 20:18:04 -07:00
Yuvi9587
fb446a1e28 Commit 2025-07-14 20:17:48 -07:00
Yuvi9587
cfd869e05a Update main_window.py 2025-07-14 09:04:34 -07:00
Yuvi9587
b191776f65 Commit 2025-07-14 08:19:58 -07:00
Yuvi9587
f41f354737 Update main_window.py 2025-07-13 21:46:34 -07:00
Yuvi9587
6b57ee099d Commit 2025-07-13 21:45:30 -07:00
Yuvi9587
21ecb60cb5 commit 2025-07-13 20:21:17 -07:00
Yuvi9587
ee00019f2e Update workers.py 2025-07-13 18:42:56 -07:00
Yuvi9587
d49c739fe4 Commit 2025-07-13 10:36:52 -07:00
Yuvi9587
dbdf82a079 Commit 2025-07-13 10:22:06 -07:00
Yuvi9587
f0bf74da16 Update readme.md 2025-07-11 01:30:07 -07:00
Yuvi9587
e8b655e492 Update readme.md 2025-07-11 01:28:48 -07:00
Yuvi9587
4f383910d2 Update readme.md 2025-07-11 01:26:57 -07:00
Yuvi9587
404c4ca59a commit 2025-07-11 01:24:56 -07:00
Yuvi9587
bcf26bea20 Commit 2025-07-11 01:24:12 -07:00
Yuvi9587
fa198c41c1 Commit 2025-07-10 09:59:51 -07:00
Yuvi9587
f214d2452e Update features.md 2025-07-08 13:14:46 +05:30
Yuvi9587
f39b510577 Update features.md 2025-07-08 13:03:24 +05:30
Yuvi9587
2c45c14696 Commit 2025-07-08 13:01:21 +05:30
Yuvi9587
aa2305c10e Commit 2025-07-07 14:10:52 +05:30
Yuvi9587
568c687f98 Update note.md 2025-07-06 17:34:04 +05:30
Yuvi9587
c8b77fb0d7 Commit 2025-07-05 06:02:21 +05:30
Yuvi9587
b78d543f16 Commit 2025-07-04 18:30:11 +05:30
Yuvi9587
56922dcd47 Update note.md 2025-07-03 18:41:50 +05:30
Yuvi9587
a00d9de546 Commit 2025-07-03 12:54:05 +05:30
Yuvi9587
260bf8e666 Update readme.md 2025-07-02 09:36:40 +05:30
Yuvi9587
f3d8447135 Commit 2025-07-02 09:35:51 +05:30
Yuvi9587
aa176afdb7 Commit 2025-07-02 09:30:50 +05:30
Yuvi9587
ea84750f05 Commit 2025-07-01 23:13:50 +05:30
Yuvi9587
e021a66092 Commit 2025-07-01 23:08:05 +05:30
Yuvi9587
650ef1cbb3 Merge branch 'main' of https://github.com/Yuvi9587/Kemono-Downloader 2025-07-01 22:59:30 +05:30
Yuvi9587
9082c0c94a Commit 2025-07-01 22:48:58 +05:30
Yuvi9587
783dfb985c Commit 2025-06-26 19:52:17 +05:30
Yuvi9587
bb2cf15b88 Commit 2025-06-26 19:51:38 +05:30
Yuvi9587
4b565dbadd Commit 2025-06-26 19:50:26 +05:30
Yuvi9587
95b0ab88ba Commit 2025-06-26 19:47:09 +05:30
Yuvi9587
65c5d2798e Update features.md 2025-06-25 12:24:35 +05:30
Yuvi9587
c23f18be6d main.py 2025-06-24 18:15:58 +01:00
Yuvi9587
69ddc2ca08 Commit 2025-06-24 16:31:28 +01:00
Yuvi9587
191dbc8c62 Commit 2025-06-19 08:25:30 +01:00
Yuvi9587
3c1b361fc1 Update main.py 2025-06-16 11:28:28 +01:00
Yuvi9587
953dbaebf0 Commit 2025-06-16 10:46:23 +01:00
Yuvi9587
efd5458493 Update main.py 2025-06-16 08:13:01 +01:00
Yuvi9587
3473f6540d Commit 2025-06-15 09:49:09 +01:00
Yuvi9587
7fe5f4b83e Commit 2025-06-14 11:40:44 +01:00
Yuvi9587
072b582622 Update languages.py 2025-06-14 11:32:29 +01:00
Yuvi9587
de936e8d96 Update languages.py 2025-06-14 16:04:41 +05:30
Yuvi9587
9d0f0dda23 Commit 2025-06-14 03:42:26 +01:00
Yuvi9587
222ec769db Commit 2025-06-12 09:13:06 +01:00
Yuvi9587
6771ede722 Commit 2025-06-11 16:39:02 +01:00
Yuvi9587
8199b79dc7 Update main.py 2025-06-11 14:28:26 +01:00
Yuvi9587
dfca265380 Update main.py 2025-06-11 04:03:38 +01:00
Yuvi9587
d68bab40d9 commit 2025-06-10 17:58:41 +01:00
Yuvi9587
3fc2cfde99 Commit 2025-06-10 17:21:50 +01:00
Yuvi9587
304ad2b3c1 Commit 2025-06-10 16:16:20 +01:00
Yuvi9587
64a314713e Update downloader_utils.py 2025-06-09 16:22:27 +01:00
Yuvi9587
d5d6fd91ef Commit 2025-06-09 08:35:42 +01:00
Yuvi9587
01665c366b Update main.py 2025-06-09 08:08:40 +01:00
Yuvi9587
b443ec1da9 Update downloader_utils.py 2025-06-09 07:22:07 +01:00
Yuvi9587
ae4ee57500 Update security.md 2025-06-07 23:34:26 +05:30
Yuvi9587
11d0515f8b Merge branch 'main' of https://github.com/Yuvi9587/Kemono-Downloader 2025-06-07 15:58:11 +01:00
Yuvi9587
1b95f13b37 Update readme.md 2025-06-07 16:38:54 +01:00
Yuvi9587
1cb70e2d4d Update 2025-06-07 16:38:23 +01:00
Yuvi9587
2bda267c3e Update readme.md 2025-06-07 20:31:20 +05:30
Yuvi9587
a721900179 Its a Big file 2025-06-07 15:57:24 +01:00
Yuvi9587
b4bea4d4a3 Update security.md 2025-06-07 20:09:50 +05:30
Yuvi9587
373c0c868c Update security.md 2025-06-07 20:09:32 +05:30
Yuvi9587
6960cbed9a Commit 2025-06-07 20:08:11 +05:30
Yuvi9587
8645c0c290 Update drive.py 2025-06-07 19:57:39 +05:30
Yuvi9587
76486a92fd Update security.md 2025-06-07 18:04:01 +05:30
Yuvi9587
823bd438bc Update security.md 2025-06-07 18:03:31 +05:30
Yuvi9587
360c0c247a Update security.md 2025-06-07 18:02:33 +05:30
Yuvi9587
474ba0280a Update security.md 2025-06-07 17:50:02 +05:30
Yuvi9587
d7fa6b1bd6 Commit 2025-06-07 17:48:47 +05:30
Yuvi9587
deb543b596 Create security.md 2025-06-07 17:48:12 +05:30
Yuvi9587
e32eb98bb7 Commit 2025-06-07 17:17:28 +05:30
Yuvi9587
461249b8ba Update 2025-06-07 11:55:56 +01:00
Yuvi9587
f8d67b0555 Commit 2025-06-07 11:49:04 +01:00
Yuvi9587
9701abde5f Update drive.py 2025-06-07 02:56:55 +01:00
Yuvi9587
0940bdb8dd Commit 2025-06-06 17:29:17 +01:00
Yuvi9587
b744e83f09 Update main.py 2025-06-06 14:30:09 +01:00
Yuvi9587
811b7b765c Commit 2025-06-06 13:59:09 +01:00
Yuvi9587
3bc3c7b760 Commit 2025-06-06 13:09:06 +01:00
Yuvi9587
d8ed588033 Commit 2025-06-06 12:49:10 +01:00
Yuvi9587
f6b7919043 Commit 2025-06-06 05:14:07 +01:00
Yuvi9587
401ccd9884 Commit 2025-06-06 04:55:51 +01:00
Yuvi9587
3b010b8eeb Commit 2025-06-05 18:23:08 +01:00
Yuvi9587
da29ccfc1f Commit 2025-06-05 18:00:12 +01:00
Yuvi9587
3197be300f Update downloader_utils.py 2025-06-04 18:47:17 +01:00
Yuvi9587
2cf73e6dbd Update downloader_utils.py 2025-06-04 16:09:00 +01:00
Yuvi9587
bd46002684 Update downloader_utils.py 2025-06-04 04:01:01 +01:00
Yuvi9587
5a6474cb8a commit 2025-06-04 03:36:39 +01:00
Yuvi9587
cdf4e9bdfb Commit 2025-06-03 18:07:20 +01:00
Yuvi9587
10b2ec666f Commit 2025-06-03 17:59:53 +01:00
Yuvi9587
08dac4df1e Commit 2025-06-02 13:13:23 +01:00
Yuvi9587
b3c837e88a Commit 2025-06-02 12:40:07 +01:00
Yuvi9587
e395a8411d Commi 2025-06-02 08:08:10 +01:00
Yuvi9587
ec9e595167 Update readme.md 2025-06-02 04:06:07 +01:00
Yuvi9587
5ff87f914a Commit 2025-06-02 04:04:17 +01:00
Yuvi9587
318b9095a7 Commit 2025-06-02 04:03:34 +01:00
Yuvi9587
437df4e73a Commit 2025-06-02 04:00:39 +01:00
Yuvi9587
3eb26bcf0c Commit 2025-06-02 03:57:14 +01:00
Yuvi9587
db7a08f18a Commit 2025-06-02 03:55:54 +01:00
Yuvi9587
dc1314a148 Update main.py 2025-06-01 12:54:41 +01:00
Yuvi9587
21ba95e325 Update readme.md 2025-06-01 12:53:36 +01:00
Yuvi9587
9367970ec0 Update readme.md 2025-06-01 12:53:01 +01:00
Yuvi9587
c34863a397 Commit 2025-06-01 12:52:44 +01:00
Yuvi9587
f93795e370 Commit 2025-06-01 08:22:29 +01:00
Yuvi9587
7d4e785ca1 commit 2025-05-31 17:23:23 +01:00
Yuvi9587
31b1cb2873 Commit 2025-05-31 15:12:00 +01:00
Yuvi9587
5e23e544e8 Commit 2025-05-31 13:12:03 +01:00
Yuvi9587
80feac092d Commit 2025-05-31 03:38:27 +01:00
Yuvi9587
9e73125d69 readme.md 2025-05-31 03:16:13 +01:00
Yuvi9587
b32cbf0dfd Update main.py 2025-05-30 17:58:25 +01:00
Yuvi9587
a9c9fde855 Commit 2025-05-30 17:57:00 +01:00
Yuvi9587
46658a7bab Update readme.md 2025-05-30 21:09:15 +05:30
Yuvi9587
927c11f2bb Update readme.md 2025-05-30 21:08:23 +05:30
Yuvi9587
a54f2b3567 Commit 2025-05-30 21:04:02 +05:30
Yuvi9587
7f2312b64f Commit 2025-05-30 08:28:11 +05:30
Yuvi9587
7106694bcb Update main.py 2025-05-29 20:29:19 +05:30
Yuvi9587
6b37d73e5a Commit 2025-05-29 19:47:04 +05:30
Yuvi9587
d1c5b205ef Commit 2025-05-29 19:09:08 +05:30
Yuvi9587
10b567a5fd Commit 2025-05-29 19:07:28 +05:30
Yuvi9587
eed0a919aa Commit 2025-05-29 17:56:16 +05:30
Yuvi9587
78357df07f Commit 2025-05-29 08:50:01 +01:00
Yuvi9587
8137c76eb4 Commit 2025-05-28 20:33:06 +05:30
Yuvi9587
be3a522305 Commit 2025-05-28 18:20:54 +05:30
Yuvi9587
13d05765b2 Commit 2025-05-28 13:41:39 +05:30
Yuvi9587
f52d16d1e4 Commit 2025-05-28 04:41:09 +05:30
Yuvi9587
acb91c7e8a Commit 2025-05-28 09:46:03 +05:30
Yuvi9587
c765a7a281 Commit 2025-05-28 09:21:34 +05:30
Yuvi9587
5abfcc8550 Merge branch 'main' of https://github.com/Yuvi9587/Kemono-Downloader 2025-05-28 08:26:03 +05:30
Yuvi9587
7957468077 Commit 2025-05-27 23:00:16 +05:30
Yuvi9587
f774773b63 Commit 2025-05-27 20:34:38 +05:30
Yuvi9587
8036cb9835 Commit 2025-05-26 20:37:37 +05:30
Yuvi9587
13fc33d2c0 Commit 2025-05-26 09:33:45 +05:30
Yuvi9587
8663ef54a3 Commit 2025-05-26 08:43:13 +05:30
Yuvi9587
0316813792 Delete dist directory 2025-05-26 13:55:54 +05:30
Yuvi9587
d201a5396c Delete build/Kemono Downloader directory 2025-05-26 13:55:25 +05:30
Yuvi9587
86f9396b6c Commit 2025-05-26 13:52:34 +05:30
Yuvi9587
0fb4bb3cb0 Commit 2025-05-26 13:52:07 +05:30
Yuvi9587
1528d7ce25 Update Read.png 2025-05-26 09:54:26 +05:30
Yuvi9587
4e7eeb7989 Commit 2025-05-26 09:52:06 +05:30
Yuvi9587
7f2976a4f4 Commit 2025-05-26 09:48:00 +05:30
Yuvi9587
8928cb92da readme.md 2025-05-26 01:39:39 +05:30
Yuvi9587
a181b76124 Update main.py 2025-05-25 17:18:11 +05:30
Yuvi9587
8f085a8f63 Commit 2025-05-25 21:52:04 +05:30
Yuvi9587
93a997351b Update readme.md 2025-05-25 21:22:47 +05:30
Yuvi9587
b3af6c1c15 Commit 2025-05-25 21:21:00 +05:30
Yuvi9587
4a65263f7d Commit 2025-05-25 19:49:17 +05:30
Yuvi9587
1091b5b9b4 Commit 2025-05-25 19:48:08 +05:30
Yuvi9587
f6b3ff2f5c Update main.py 2025-05-25 11:36:35 +05:30
Yuvi9587
b399bdf5cf readme.md 2025-05-25 16:54:35 +05:30
Yuvi9587
9ace161bc8 Update downloader_utils.py 2025-05-25 11:22:04 +05:30
Yuvi9587
66e52cfd78 Commit 2025-05-25 12:27:15 +05:30
Yuvi9587
e665fd3cde Commit 2025-05-25 11:38:38 +05:30
Yuvi9587
fc94f4c691 Commit 2025-05-24 22:55:23 +05:30
Yuvi9587
78e2012f04 Commit 2025-05-24 13:30:06 +05:30
Yuvi9587
3fe9dbacc6 Commit 2025-05-24 13:15:08 +05:30
Yuvi9587
004dea06e0 Commit 2025-05-24 16:22:47 +05:30
Yuvi9587
8994a69c34 Add files via upload 2025-05-24 10:36:15 +05:30
Yuvi9587
f4a692673e main.py 2025-05-24 10:35:46 +05:30
Yuvi9587
4cb5f14ef6 Delete Known.txt 2025-05-23 21:01:05 +05:30
Yuvi9587
a596c4f350 Update main.py 2025-05-23 20:59:35 +05:30
Yuvi9587
e091c60d29 Commit 2025-05-23 20:23:36 +05:30
Yuvi9587
d2ea026a41 Commit 2025-05-23 19:11:52 +05:30
70 changed files with 2309582 additions and 5925 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
github: [Yuvi9587]
ko_fi: yuvi427183
buy_me_a_coffee: yuvi9587

View File

@@ -1 +0,0 @@
([Yor], Yor Briar, Yor Forger)

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 [Yuvi9587]
Copyright (c) [2025] [Yuvi9587]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

BIN
Read.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

BIN
Read/Cat.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

BIN
Read/Read.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
Read/Read1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
Read/Read2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
Read/Read3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
Read/bmac.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

BIN
assets/Kemono.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/discord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/github.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
assets/instagram.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

2291826
data/creators.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
Fonts are (c) Bitstream (see below). DejaVu changes are in public domain.
Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below)
Bitstream Vera Fonts Copyright
------------------------------
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is
a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Arev Fonts Copyright
------------------------------
Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.
Permission is hereby granted, free of charge, to any person obtaining
a copy of the fonts accompanying this license ("Fonts") and
associated documentation files (the "Font Software"), to reproduce
and distribute the modifications to the Bitstream Vera Font Software,
including without limitation the rights to use, copy, merge, publish,
distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to
the following conditions:
The above copyright and trademark notices and this permission notice
shall be included in all copies of one or more of the Font Software
typefaces.
The Font Software may be modified, altered, or added to, and in
particular the designs of glyphs or characters in the Fonts may be
modified and additional glyphs or characters may be added to the
Fonts, only if the fonts are renamed to names not containing either
the words "Tavmjong Bah" or the word "Arev".
This License becomes null and void to the extent applicable to Fonts
or Font Software that has been modified and is distributed under the
"Tavmjong Bah Arev" names.
The Font Software may be sold as part of a larger software package but
no copy of one or more of the Font Software typefaces may be sold by
itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL
TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the name of Tavmjong Bah shall not
be used in advertising or otherwise to promote the sale, use or other
dealings in this Font Software without prior written authorization
from Tavmjong Bah. For further information, contact: tavmjong @ free
. fr.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

147
features.md Normal file
View File

@@ -0,0 +1,147 @@
<div>
<h1>Kemono Downloader - Comprehensive Feature Guide</h1>
<p>This guide provides a detailed overview of all user interface elements, input fields, buttons, popups, and functionalities available in the application.</p>
<hr>
<h2><strong>Main Window: Core Functionality</strong></h2>
<p>The application is divided into a configuration panel on the left and a status/log panel on the right.</p>
<h3><strong>Primary Inputs (Top-Left)</strong></h3>
<ul>
<li><strong>URL Input Field</strong>: This is the starting point for most downloads. You can paste a URL for a specific post or for an entire creator's feed. The application's behavior adapts based on the URL type.</li>
<li><strong>🎨 Creator Selection Popup</strong>: This button opens a powerful dialog listing all known creators. From here, you can:
<ul>
<li><strong>Search and Queue</strong>: Search for creators and check multiple names. Clicking "Add Selected" populates the main input field, preparing a batch download.</li>
<li><strong>Check for Updates</strong>: Select a single creator's saved profile. This loads their information and switches the main download button to "Check for Updates" mode, allowing you to download only new content since your last session.</li>
</ul>
</li>
<li><strong>Download Location</strong>: The primary folder where all content will be saved. The <strong>Browse...</strong> button lets you select this folder from your computer.</li>
<li><strong>Page Range (Start/End)</strong>: These fields activate only for creator feed URLs. They allow you to download a specific slice of a creator's history (e.g., pages 5 through 10) instead of their entire feed.</li>
</ul>
<hr>
<h2><strong>Filtering & Naming (Left Panel)</strong></h2>
<p>These features give you precise control over what gets downloaded and how it's named and organized.</p>
<ul>
<li><strong>Filter by Character(s)</strong>: A powerful tool to download content featuring specific characters. You can enter multiple names separated by commas.
<ul>
<li><strong>Filter: [Scope] Button</strong>: This button changes how the character filter works:
<ul>
<li><strong>Title</strong>: Downloads posts only if a character's name is in the post title.</li>
<li><strong>Files</strong>: Downloads posts if a character's name is in any of the filenames within the post.</li>
<li><strong>Both</strong>: Combines the "Title" and "Files" logic.</li>
<li><strong>Comments (Beta)</strong>: Downloads a post if a character's name is mentioned in the comments section.</li>
</ul>
</li>
</ul>
</li>
<li><strong>Skip with Words</strong>: A keyword-based filter to avoid unwanted content (e.g., <code>WIP</code>, <code>sketch</code>).
<ul>
<li><strong>Scope: [Type] Button</strong>: This button changes how the skip filter works:
<ul>
<li><strong>Posts</strong>: Skips the entire post if a keyword is found in the title.</li>
<li><strong>Files</strong>: Skips only individual files if a keyword is found in the filename.</li>
<li><strong>Both</strong>: Applies both levels of skipping.</li>
</ul>
</li>
</ul>
</li>
<li><strong>Remove Words from name</strong>: Automatically cleans downloaded filenames by removing any specified words (e.g., "patreon," "HD").</li>
</ul>
<h3><strong>File Type Filter (Radio Buttons)</strong></h3>
<p>This section lets you choose the kind of content you want:</p>
<ul>
<li><strong>All, Images/GIFs, Videos, 🎧 Only Audio, 📦 Only Archives</strong>: These options filter the downloads to only include the selected file types.</li>
<li><strong>🔗 Only Links</strong>: This special mode doesn't download any files. Instead, it scans post descriptions and lists all external links (like Mega, Google Drive) in the log panel.</li>
<li><strong>More</strong>: Opens a dialog for text-only downloads. You can choose to save post <strong>descriptions</strong> or <strong>comments</strong> as formatted <strong>PDF, DOCX, or TXT</strong> files. A key feature here is the <strong>"Single PDF"</strong> option, which compiles the text from all downloaded posts into one continuous, sorted PDF document.</li>
</ul>
<hr>
<h2><strong>Download Options & Advanced Settings (Checkboxes)</strong></h2>
<ul>
<li><strong>Skip .zip</strong>: A simple toggle to ignore archive files during downloads.</li>
<li><strong>Download Thumbnails Only</strong>: Downloads only the small preview images instead of the full-resolution files.</li>
<li><strong>Scan Content for Images</strong>: A crucial feature that scans the post's text content for embedded images that may not be listed in the API, ensuring a more complete download.</li>
<li><strong>Compress to WebP</strong>: Saves disk space by automatically converting large images into the efficient WebP format.</li>
<li><strong>Keep Duplicates</strong>: Opens a dialog to control how files with identical content are handled. The default is to skip duplicates, but you can choose to keep all of them or set a specific limit (e.g., "keep up to 2 copies of the same file").</li>
<li><strong>Subfolder per Post</strong>: Organizes downloads by creating a unique folder for each post, named after the post's title.</li>
<li><strong>Date Prefix</strong>: When "Subfolder per Post" is on, this adds the post's date to the beginning of the folder name (e.g., <code>2025-07-25 Post Title</code>).</li>
<li><strong>Separate Folders by Known.txt</strong>: This enables the automatic folder organization system based on your "Known Names" list.</li>
<li><strong>Use Cookie</strong>: Allows the application to use browser cookies to access content that might be behind a paywall or login. You can paste a cookie string directly or use <strong>Browse...</strong> to select a <code>cookies.txt</code> file.</li>
<li><strong>Use Multithreading</strong>: Greatly speeds up downloads of creator feeds by processing multiple posts at once. The number of <strong>Threads</strong> can be configured.</li>
<li><strong>Show External Links in Log</strong>: When checked, a secondary log panel appears at the bottom of the right side, dedicated to listing any external links found.</li>
</ul>
<hr>
<h2><strong>Known Names Management (Bottom-Left)</strong></h2>
<p>This powerful feature automates the creation of organized, named folders.</p>
<ul>
<li><strong>Known Shows/Characters List</strong>: Displays all the names and groups you've saved.</li>
<li><strong>Search...</strong>: Filters the list to quickly find a name.</li>
<li><strong>Open Known.txt</strong>: Opens the source file in a text editor for advanced manual editing.</li>
<li><strong>Add New Name</strong>:
<ul>
<li><strong>Single Name</strong>: Typing <code>Tifa Lockhart</code> and clicking <strong> Add</strong> creates an entry that will match "Tifa Lockhart".</li>
<li><strong>Group</strong>: Typing <code>(Boa, Hancock, Snake Princess)~</code> and clicking <strong> Add</strong> creates a single entry named "Boa Hancock Snake Princess". The application will then look for "Boa," "Hancock," OR "Snake Princess" in titles/filenames and save any matches into that combined folder.</li>
</ul>
</li>
<li><strong>⤵️ Add to Filter</strong>: Opens a dialog with your full Known Names list, allowing you to check multiple entries and add them all to the "Filter by Character(s)" field at once.</li>
<li><strong>🗑️ Delete Selected</strong>: Removes highlighted names from your list.</li>
</ul>
<hr>
<h2><strong>Action Buttons & Status Controls</strong></h2>
<ul>
<li><strong>⬇️ Start Download / 🔗 Extract Links</strong>: The main action button. Its function is dynamic:
<ul>
<li><strong>Normal Mode</strong>: Starts the download based on the current settings.</li>
<li><strong>Update Mode</strong>: After selecting a creator profile, this button changes to <strong>🔄 Check for Updates</strong>.</li>
<li><strong>Update Confirmation</strong>: After new posts are found, it changes to <strong>⬇️ Start Download (X new)</strong>.</li>
<li><strong>Link Extraction Mode</strong>: The text changes to <strong>🔗 Extract Links</strong>.</li>
</ul>
</li>
<li><strong>⏸️ Pause / ▶️ Resume Download</strong>: Pauses the ongoing download, allowing you to change certain settings (like filters) on the fly. Click again to resume.</li>
<li><strong>❌ Cancel & Reset UI</strong>: Immediately stops all download activity and resets the UI to a clean state, preserving your URL and Download Location inputs.</li>
<li><strong>Error Button</strong>: If files fail to download, they are logged. This button opens a dialog listing all failed files and will show a count of errors (e.g., <strong>(5) Error</strong>). From the dialog, you can:
<ul>
<li>Select specific files to <strong>Retry</strong> downloading.</li>
<li><strong>Export</strong> the list of failed URLs to a <code>.txt</code> file.</li>
</ul>
</li>
<li><strong>🔄 Reset (Top-Right)</strong>: A hard reset that clears all logs and returns every single UI element to its default state.</li>
<li><strong>⚙️ (Settings)</strong>: Opens the main Settings dialog.</li>
<li><strong>📜 (History)</strong>: Opens the Download History dialog.</li>
<li><strong>? (Help)</strong>: Opens a helpful guide explaining the application's features.</li>
<li><strong>❤️ Support</strong>: Opens a dialog with information on how to support the developer.</li>
</ul>
<hr>
<h2><strong>Specialized Modes & Features</strong></h2>
<h3><strong>⭐ Favorite Mode</strong></h3>
<p>Activating this mode transforms the UI for managing saved collections:</p>
<ul>
<li>The URL input is disabled.</li>
<li>The main action buttons are replaced with:
<ul>
<li><strong>🖼️ Favorite Artists</strong>: Opens a dialog to browse and queue downloads from your saved favorite creators.</li>
<li><strong>📄 Favorite Posts</strong>: Opens a dialog to browse and queue downloads for specific saved favorite posts.</li>
</ul>
</li>
<li><strong>Scope: [Location] Button</strong>: Toggles where the favorited content is saved:
<ul>
<li><strong>Selected Location</strong>: Saves all content directly into the main "Download Location".</li>
<li><strong>Artist Folders</strong>: Creates a subfolder for each artist inside the main "Download Location".</li>
</ul>
</li>
</ul>
<h3><strong>📖 Manga/Comic Mode</strong></h3>
<p>This mode is designed for sequential content and has several effects:</p>
<ul>
<li><strong>Reverses Download Order</strong>: It fetches and downloads posts from <strong>oldest to newest</strong>.</li>
<li><strong>Enables Special Naming</strong>: A <strong><code>Name: [Style]</code></strong> button appears, allowing you to choose how files are named to maintain their correct order (e.g., by Post Title, by Date, or simple sequential numbering like <code>001, 002, 003...</code>).</li>
<li><strong>Disables Multithreading (for certain styles)</strong>: To guarantee perfect sequential numbering, multithreading for posts is automatically disabled for certain naming styles.</li>
</ul>
<h3><strong>Session & Error Management</strong></h3>
<ul>
<li><strong>Session Restore</strong>: If the application is closed unexpectedly during a download, it will detect the incomplete session on the next launch. The UI will present a <strong>🔄 Restore Download</strong> button to resume exactly where you left off. You can also choose to discard the session.</li>
<li><strong>Update Checking</strong>: By selecting a creator profile via the <strong>🎨 Creator Selection Popup</strong>, you can run an update check. The application compares the posts on the server with your download history for that creator and will prompt you to download only the new content.</li>
</ul>
<h3><strong>Logging & Monitoring</strong></h3>
<ul>
<li><strong>Progress Log</strong>: The main log provides real-time feedback on the download process, including status messages, file saves, skips, and errors.</li>
<li><strong>👁️ Log View Toggle</strong>: Switches the log view between the standard <strong>Progress Log</strong> and a <strong>Missed Character Log</strong>, which shows potential character names from posts that were skipped by your filters, helping you discover new names to add to your list.</li>
</ul>
</div>

3929
main.py

File diff suppressed because it is too large Load Diff

93
note.md Normal file
View File

@@ -0,0 +1,93 @@
# 🛠️ KemonoDownloader Refactor Notes
## What's Going On
This project used to be one giant messy App Script. It worked, but it was hard to maintain or expand. So I cleaned it up and split everything into smaller, more manageable files to make it easier to read, update, and add new stuff later.
**⚠️ Heads up:** Since I'm still in the middle of refactoring things, some features might be broken or not working right now. The layout is better, but I still need to update some parts of the logic and dependencies.
---
## 📁 Folder Layout
```
KemonoDownloader/
├── main.py # Where the app starts
├── assets/ # Icons and other static files
│ └── Kemono.ico
├── data/
│ └── creators.json
├── logs/ # Error logs and other output
│ └── uncaught_exceptions.log
└── src/ # Main code lives here
├── __init__.py
├── ui/ # UI-related code
│ ├── __init__.py
│ ├── main_window.py
│ └── dialogs/
│ ├── __init__.py
│ ├── ConfirmAddAllDialog.py
│ ├── CookieHelpDialog.py
│ ├── DownloadExtractedLinksDialog.py
│ ├── DownloadFinishedDialog.py
│ └── ... (more dialogs)
├── core/ # The brain of the app
│ ├── __init__.py
│ ├── manager.py
│ ├── workers.py
│ └── api_client.py
├── services/ # Downloading stuff happens here
│ ├── __init__.py
│ ├── drive_downloader.py
│ └── multipart_downloader.py
├── utils/ # Helper functions
│ ├── __init__.py
│ ├── file_utils.py
│ ├── network_utils.py
│ └── text_utils.py
├── config/ # Constants and settings
│ ├── __init__.py
│ └── constants.py
└── i18n/ # Translations (if needed)
├── __init__.py
└── translator.py
```
---
## ✅ Why Bother Refactoring?
- Everythings now broken into smaller parts, so its easier to work with.
- Easier to test, fix, and add stuff.
- Prepping the project to grow without becoming a mess again.
- Separated the UI from the app logic so they dont get tangled.
---
## 🚧 Whats Still Broken
- Some features dont work yet or havent been tested since the changes.
- Still need to:
- Reconnect the UI to the updated logic.
- Move over some of the old script code into proper modules.
- Make sure settings and cookies work properly in the new setup.
---
## 📌 To-Do List
- Test all the dialogs and UI stuff.
- Make sure the download services and API calls are working.
- Reconnect the UI with the new logic in `core/manager.py`.
- Add more logging and maybe some unit tests too.
---
## 🐞 Found a Bug?
If something's busted:
- Feel free to open an issue if you're using this.
- Or just message me. Feedback helps a lot while Im still figuring things out.
Thanks for checking it out! Still a work in progress, but getting there.

430
readme.md
View File

@@ -1,313 +1,217 @@
<h1 align="center">Kemono Downloader v4.0.0</h1>
<h1 align="center">Kemono Downloader v6.0.0</h1>
<div align="center">
<img src="https://github.com/Yuvi9587/Kemono-Downloader/blob/main/Read.png" alt="Kemono Downloader"/>
<table>
<tr>
<td align="center">
<img src="Read/Read.png" alt="Default Mode" width="400"><br>
<strong>Default</strong>
</td>
<td align="center">
<img src="Read/Read1.png" alt="Favorite Mode" width="400"><br>
<strong>Favorite Mode</strong>
</td>
</tr>
<tr>
<td align="center">
<img src="Read/Read2.png" alt="Single Post" width="400"><br>
<strong>Single Post</strong>
</td>
<td align="center">
<img src="Read/Read3.png" alt="Manga/Comic Mode" width="400"><br>
<strong>Manga/Comic Mode</strong>
</td>
</tr>
</table>
</div>
---
A powerful, feature-rich GUI application for downloading content from **[Kemono.su](https://kemono.su)** and **[Coomer.party](https://coomer.party)**.
Built with **PyQt5**, this tool is ideal for users who want deep filtering, customizable folder structures, efficient downloads, and intelligent automation — all within a modern, user-friendly graphical interface.
A powerful, feature-rich GUI application for downloading content from **[Kemono.su](https://kemono.su)** (and its mirrors like kemono.party) and **[Coomer.party](https://coomer.party)** (and its mirrors like coomer.su).
Built with PyQt5, this tool is designed for users who want deep filtering capabilities, customizable folder structures, efficient downloads, and intelligent automation — all within a modern and user-friendly graphical interface.
<div align="center">
[![](https://img.shields.io/badge/📚%20Full%20Feature%20List-FFD700?style=for-the-badge&logoColor=black&color=FFD700)](features.md)
[![](https://img.shields.io/badge/📝%20License-90EE90?style=for-the-badge&logoColor=black&color=90EE90)](LICENSE)
[![](https://img.shields.io/badge/⚠️%20Important%20Note-FFCCCB?style=for-the-badge&logoColor=black&color=FFCCCB)](note.md)
</div>
---
## What's New in v4.0.0?
## Feature Overview
Version 3.5.0 focuses on enhancing access to content and providing even smarter organization:
Kemono Downloader offers a range of features to streamline your content downloading experience:
### Cookie Management
- **User-Friendly Interface:** A modern PyQt5 GUI for easy navigation and operation.
- **Access Content:** Seamlessly download from Kemono/Coomer as if you were logged in by using your browser's cookies.
- **Flexible Input:**
- Directly paste your cookie string (e.g., `name1=value1; name2=value2`).
- Browse and load cookies from a `cookies.txt` file (Netscape format).
- Automatic fallback to a `cookies.txt` file in the application directory if "Use Cookie" is enabled and no other source is specified.
- **Easy Activation:** A simple "Use Cookie" checkbox in the UI controls this feature.
- *Important Note: Cookie settings (text, file path, and enabled state) are configured per session and are not saved when the application is closed. You will need to re-apply them on each launch if needed.*
- **Flexible Downloading:**
- Download content from Kemono.su (and mirrors) and Coomer.party (and mirrors).
- Supports creator pages (with page range selection) and individual post URLs.
- Standard download controls: Start, Pause, Resume, and Cancel.
- **Powerful Filtering:**
- **Character Filtering:** Filter content by character names. Supports simple comma-separated names and grouped names for shared folders.
- **Keyword Skipping:** Skip posts or files based on specified keywords.
- **Filename Cleaning:** Remove unwanted words or phrases from downloaded filenames.
- **File Type Selection:** Choose to download all files, or limit to images/GIFs, videos, audio, or archives. Can also extract external links only.
- **Customizable Downloads:**
- **Thumbnails Only:** Option to download only small preview images.
- **Content Scanning:** Scan post HTML for `<img>` tags and direct image links, useful for images embedded in descriptions.
- **WebP Conversion:** Convert images to WebP format for smaller file sizes (requires Pillow library).
- **Organized Output:**
- **Automatic Subfolders:** Create subfolders based on character names (from filters or `Known.txt`) or post titles.
- **Per-Post Subfolders:** Option to create an additional subfolder for each individual post.
- **Manga/Comic Mode:**
- Downloads posts from a creator's feed in chronological order (oldest to newest).
- Offers various filename styling options for sequential reading (e.g., post title, original name, global numbering).
- **⭐ Favorite Mode:**
- Directly download from your favorited artists and posts on Kemono.su.
- Requires a valid cookie and adapts the UI for easy selection from your favorites.
- Supports downloading into a single location or artist-specific subfolders.
- **Performance & Advanced Options:**
- **Cookie Support:** Use cookies (paste string or load from `cookies.txt`) to access restricted content.
- **Multithreading:** Configure the number of simultaneous downloads/post processing threads for improved speed.
- **Logging:**
- A detailed progress log displays download activity, errors, and summaries.
- **Multi-language Interface:** Choose from several languages for the UI (English, Japanese, French, Spanish, German, Russian, Korean, Chinese Simplified).
- **Theme Customization:** Selectable Light and Dark themes for user comfort.
---
### Advanced `Known.txt` and Character Filtering
## ✨ What's New in v6.0.0
The `Known.txt` system has been revamped for improved performance and stability. The previous method of handling known names could become resource-intensive with large lists, potentially leading to application slowdowns or crashes. This new, streamlined system offers more direct control and robust organization.
The `Known.txt` file and the "Filter by Character(s)" input field work together to provide powerful and flexible content organization. The `Known.txt` file itself has a straightforward syntax, while the UI input allows for more complex session-specific grouping and alias definitions that can then be added to `Known.txt`.
This release focuses on providing more granular control over file organization and improving at-a-glance status monitoring.
**1. `Known.txt` File Syntax (Located in App Directory):**
### New Features
`Known.txt` stores your persistent list of characters, series, or keywords for folder organization. Each line is an entry:
- **Live Error Count on Button**
The **"Error" button** now dynamically displays the number of failed files during a download. Instead of opening the dialog, you can quickly see a live count like `(3) Error`, helping you track issues at a glance.
- **Simple Entries:**
- A line like `My Awesome Series` or `Nami`.
- **Behavior:** Content matching this term will be saved into a folder named "My Awesome Series" or "Nami" respectively (if "Separate Folders" is enabled).
- **Date Prefix for Post Subfolders**
A new checkbox labeled **"Date Prefix"** is now available in the advanced settings.
When enabled alongside **"Subfolder per Post"**, it prepends the post's upload date to the folder name (e.g., `2025-07-11 Post Title`).
This makes your downloads sortable and easier to browse chronologically.
**2. "Filter by Character(s)" UI Input Field:**
- **Keep Duplicates Within a Post**
A **"Keep Duplicates"** option has been added to preserve all files from a post — even if some have the same name.
Instead of skipping or overwriting, the downloader will save duplicates with numbered suffixes (e.g., `image.jpg`, `image_1.jpg`, etc.), which is especially useful when the same file name points to different media.
This field allows for dynamic filtering for the current download session and provides options for how new entries are added to `Known.txt`.
### Bug Fixes
- **Standard Names:**
- Input: `Nami, Robin`
- Session Behavior: Filters for "Nami" OR "Robin". If "Separate Folders" is on, creates folders "Nami" and "Robin".
- `Known.txt` Addition: If "Nami" is new and selected for addition in the confirmation dialog, it's added as `Nami` on a new line in `Known.txt`.
- **Grouped Aliases for a Single Character (using `(...)~` syntax):**
- Input: `(Boa, Hancock)~`
- Meaning: "Boa" and "Hancock" are different names/aliases for the *same character*. The names are listed within parentheses separated by commas (e.g., `name1, alias1, alias2`), and the entire group is followed by a `~` symbol. This is useful when a creator uses different names for the same character.
- Session Behavior: Filters for "Boa" OR "Hancock". If "Separate Folders" is on, creates a single folder named "Boa Hancock".
- `Known.txt` Addition: If this group is new and selected for addition, it's added to `Known.txt` as a grouped alias entry, typically `(Boa Hancock)`. The first name in the `Known.txt` entry (e.g., "Boa Hancock") becomes the primary folder name.
- **Combined Folder for Distinct Characters (using `(...)` syntax):**
- Input: `(Vivi, Uta)`
- Meaning: "Vivi" and "Uta" are *distinct characters*, but for this download session, their content should be grouped into a single folder. The names are listed within parentheses separated by commas. This is useful for grouping art of less frequent characters without creating many small individual folders.
- Session Behavior: Filters for "Vivi" OR "Uta". If "Separate Folders" is on, creates a single folder named "Vivi Uta".
- `Known.txt` Addition: If this "combined group" is new and selected for addition, "Vivi" and "Uta" are added to `Known.txt` as *separate, individual simple entries* on new lines:
```
Vivi
Uta
```
The combined folder "Vivi Uta" is a session-only convenience; `Known.txt` stores them as distinct entities for future individual use.
**3. Interaction with `Known.txt`:**
- **Adding New Names from Filters:** When you use the "Filter by Character(s)" input, if any names or groups are new (not already in `Known.txt`), a dialog will appear after you start the download. This dialog allows you to select which of these new names/groups should be added to `Known.txt`, formatted according to the rules described above.
- **Intelligent Fallback:** If "Separate Folders by Name/Title" is active, and content doesn't match the "Filter by Character(s)" UI input, the downloader consults your `Known.txt` file for folder naming.
- **Direct Management:** You can add simple entries directly to `Known.txt` using the list and "Add" button in the UI's `Known.txt` management section. For creating or modifying complex grouped alias entries directly in the file, or for bulk edits, click the "Open Known.txt" button. The application reloads `Known.txt` on startup or before a download process begins.
---
## What's in v3.5.0? (Previous Update)
This version brings significant enhancements to manga/comic downloading, filtering capabilities, and user experience:
### Enhanced Manga/Comic Mode
- **New "Date Based" Filename Style:**
- Perfect for truly sequential content! Files are named numerically (e.g., `001.jpg`, `002.jpg`, `003.ext`...) across an *entire creator's feed*, strictly following post publication order.
- **Smart Numbering:** Automatically resumes from the highest existing number found in the series folder (and subfolders, if "Subfolder per Post" is enabled).
- **Guaranteed Order:** Disables multi-threading for post processing to ensure sequential accuracy.
- Works alongside the existing "Post Title" and "Original File Name" styles.
- The downloader now correctly renames large `.part` files when completed, avoiding leftover temp files.
- The list of failed files shown in the Error Dialog is now saved and restored with your session — so no errors get lost if you close the app.
- Your selected download location is remembered, even after pressing the **Reset** button.
- The **Cancel** button is now enabled when restoring a pending session, so you can abort stuck jobs more easily.
- Internal cleanup logs (like "Deleting post cache") are now excluded from the final download summary for clarity.
---
### "Remove Words from Filename" Feature
## 📅 Next Update Plans
- Specify comma-separated words or phrases (case-insensitive) that will be automatically removed from filenames.
### 🔖 Post Tag Filtering (Planned for v6.1.0)
- Example: `patreon, [HD], _final` transforms `AwesomeArt_patreon` `Hinata_Hd` into `AwesomeArt.jpg` `Hinata.jpg`.
A powerful new **"Filter by Post Tags"** feature is planned:
- Filter and download content based on specific post tags.
- Combine tag filtering with current filters (character, file type, etc.).
- Use tag presets to automate frequent downloads.
This will provide **much greater control** over what gets downloaded, especially for creators who use tags consistently.
### 📁 Creator Download History (.json Save)
To streamline incremental downloads, a new system will allow the app to:
- Save a `.json` file with metadata about already-downloaded posts.
- Compare that file on future runs, so only **new** posts are downloaded.
- Avoids duplication and makes regular syncs fast and efficient.
Ideal for users managing large collections or syncing favorites regularly.
---
### New "Only Archives" File Filter Mode
- Exclusively downloads `.zip` and `.rar` files.
- Automatically disables conflicting options like "Skip .zip/.rar" and external link logging.
---
### Improved Character Filter Scope - "Comments (Beta)"
- **File-First Check:** Prioritizes matching filenames before checking post comments for character names.
- **Comment Fallback:** Only checks comments if no filename match is found, reducing unnecessary API calls.
---
### Refined "Missed Character Log"
- Displays a capitalized, alphabetized list of key terms from skipped post titles.
- Makes it easier to spot patterns or characters that might be unintentionally excluded.
---
### Enhanced Multi-part Download Progress
- Granular visibility into active chunk downloads and combined speed for large files.
---
### Updated Onboarding Tour
- Improved guide for new users, covering v4.0.0 features and existing core functions.
---
### Robust Configuration Path
- Settings and `Known.txt` are now stored in the same folder as app.
---
## Core Features
---
### User Interface & Workflow
- **Clean PyQt5 GUI** — Simple, modern, and dark-themed.
- **Persistent Settings** — Saves preferences between sessions.
- **Download Modes:**
- Single Post URL
- Entire Creator Feed
- **Flexible Options:**
- Specify Page Range (disabled in Manga Mode)
- Custom Folder Name for single posts
---
### Smart Filtering
- **Character Name Filtering:**
- Use `Tifa, Aerith` or group `(Boa, Hancock)` → folder `Boa Hancock`
- Flexible input for current session and for adding to `Known.txt`.
- Examples:
- `Nami` (simple character)
- `(Boa Hancock)~` (aliases for one character, session folder "Boa Hancock", adds `(Boa Hancock)` to `Known.txt`)
- `(Vivi, Uta)` (distinct characters, session folder "Vivi Uta", adds `Vivi` and `Uta` separately to `Known.txt`)
- See "Advanced `Known.txt` and Character Filtering" for full details.
- **Filter Scopes:**
- `Files`
- `Title`
- `Both (Title then Files)`
- `Comments (Beta - Files first)`
- **Skip with Words:**
- Exclude with `WIP, sketch, preview`
- **Skip Scopes:**
- `Files`
- `Posts`
- `Both (Posts then Files)`
- **File Type Filters:**
- `All`, `Images/GIFs`, `Videos`, `📦 Only Archives`, `🔗 Only Links`
- **Filename Cleanup:**
- Remove illegal and unwanted characters or phrases
---
### Manga/Comic Mode (Creator Feeds Only)
- **Chronological Processing** — Oldest posts first
- **Filename Style Options:**
- `Name: Post Title (Default)`
- `Name: Original File`
- `Name: Date Based (New)`
- **Best With:** Character filters set to manga/series title
---
### Folder Structure & Naming
- **Subfolders:**
- Auto-created based on character name, post title, or `Known.txt`
- "Subfolder per Post" option for further nesting
- **Smart Naming:** Cleans invalid characters and structures logically
---
### Thumbnail & Compression Tools
- **Download Thumbnails Only**
- **Compress to WebP** (via Pillow)
- Converts large images to smaller WebP versions
---
### Performance Features
- **Multithreading:**
- For both post processing and file downloading
- **Multi-part Downloads:**
- Toggleable in GUI
- Splits large files into chunks
- Granular chunk-level progress display
---
### Logging & Progress
- **Real-time Logs:** Activity, errors, skipped posts
- **Missed Character Log:** Shows skipped keywords in easy-to-read list
- **External Links Log:** Shows links (unless disabled in some modes)
- **Export Links:** Save `.txt` of links (Only Links mode)
---
### Config System
- **`Known.txt` for Smart Folder Naming (Located in App Directory):**
- A user-editable file that stores a list of preferred names, series titles, or keywords.
- It's primarily used as an intelligent fallback for folder creation when "Separate Folders by Name/Title" is enabled.
- **Syntax:**
- Simple entries: `My Favorite Series` (creates folder "My Favorite Series", matches "My Favorite Series").
- Grouped entries: `(Desired Folder Name, alias1, alias2)` (creates folder "Desired Folder Name"; matches "Desired Folder Name", "alias1", or "alias2").
- **Settings Stored in App Directory**
- **Editable Within GUI**
---
## Installation
---
## 💻 Installation
### Requirements
- Python 3.6 or higher
- pip
---
- Python 3.6 or higher
- pip (Python package installer)
### Install Dependencies
```bash
pip install PyQt5 requests Pillow
pip install PyQt5 requests Pillow mega.py
```
***
## ** Build a Standalone Executable (Optional)**
1. Install PyInstaller:
### Running the Application
Navigate to the application's directory in your terminal and run:
```bash
pip install pyinstaller
python main.py
```
2. Run:
### Optional Setup
- **Main Inputs:**
- Place your `cookies.txt` in the root directory (if using cookies).
- Prepare your `Known.txt` and `creators.json` in the same directory for advanced filtering and selection features.
---
## Troubleshooting
### AttributeError: module 'asyncio' has no attribute 'coroutine'
If you encounter an error message similar to:
```
AttributeError: module 'asyncio' has no attribute 'coroutine'. Did you mean: 'coroutines'?
```
This usually means that a dependency, often `tenacity` (used by `mega.py`), is an older version that's incompatible with your Python version (typically Python 3.10+).
To fix this, activate your virtual environment and run the following commands to upgrade the libraries:
```bash
pyinstaller --name "Kemono Downloader" --onefile --windowed --icon="Kemono.ico" main.py
pip install --upgrade tenacity
pip install --upgrade mega.py
```
3. Output will be in the `dist/` folder.
---
***
## Contribution
## ** Config Files**
- `settings.json` — Stores your UI preferences and settings.
- `Known.txt` — Stores character names, series titles, or keywords for organizing downloaded content into specific folders.
- Supports simple entries (e.g., `My Series`) and grouped entries for aliases (e.g., `(Folder Name, alias1, alias2)` where "Folder Name" is the name of the created folder, and all terms are used for matching).
Feel free to fork this repo and submit pull requests for bug fixes, new features, or UI improvements!
***
---
## ** Feedback & Support**
## License
This project is under the Custom Licence
## Star History
<table align="center" style="border-collapse: collapse; border: none; margin-left: auto; margin-right: auto;">
<tr>
<td align="center" valign="middle" style="padding: 10px; border: none;">
<a href="https://www.star-history.com/#Yuvi9587/Kemono-Downloader&Date">
<img src="https://api.star-history.com/svg?repos=Yuvi9587/Kemono-Downloader&type=Date" alt="Star History Chart" width="650">
</a>
</table>
<p align="center">
<a href="https://buymeacoffee.com/yuvi9587">
<img src="https://img.shields.io/badge/🍺%20Buy%20Me%20a%20Coffee-FFCCCB?style=for-the-badge&logoColor=black&color=FFDD00" alt="Buy Me a Coffee">
</a>
</p>
Issues? Suggestions?
Open an issue on the [GitHub repository](https://github.com/Yuvi9587/kemono-downloader) or join our community.

19
security.md Normal file
View File

@@ -0,0 +1,19 @@
# Security Policy
## Supported Versions
We are committed to maintaining and improving the Kemono Downloader. For the best experience and access to the latest security updates and features, we strongly recommend using the most recent versions of the application.
| Version | Supported Status |
| -------------- | ------------------------------------ |
| >= 5.0.0 | :white_check_mark: Actively Supported |
| 4.0.0 - 4.x.x | :warning: Supported (Limited Features) |
| < 4.0.0 | :x: End of Life (EOL) |
Users are encouraged to update to **v5.0.0 or newer** versions.
## Active Maintenance
The Kemono Downloader is actively maintained. We strive to address bugs, implement new features in a timely manner. If you discover any security vulnerabilities, please report them(details on reporting to be added if a formal process is established).
We appreciate your help in keeping Kemono Downloader secure!

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
# ...existing code...

1
src/config/__init__.py Normal file
View File

@@ -0,0 +1 @@
# ...existing code...

120
src/config/constants.py Normal file
View File

@@ -0,0 +1,120 @@
# --- Application Metadata ---
CONFIG_ORGANIZATION_NAME = "KemonoDownloader"
CONFIG_APP_NAME_MAIN = "ApplicationSettings"
CONFIG_APP_NAME_TOUR = "ApplicationTour"
# --- Filename and Folder Naming Styles ---
STYLE_POST_TITLE = "post_title"
STYLE_ORIGINAL_NAME = "original_name"
STYLE_DATE_BASED = "date_based"
STYLE_DATE_POST_TITLE = "date_post_title"
STYLE_POST_TITLE_GLOBAL_NUMBERING = "post_title_global_numbering"
STYLE_POST_ID = "post_id" # Add this line
MANGA_DATE_PREFIX_DEFAULT = ""
# --- Download Scopes ---
SKIP_SCOPE_FILES = "files"
SKIP_SCOPE_POSTS = "posts"
SKIP_SCOPE_BOTH = "both"
CHAR_SCOPE_TITLE = "title"
CHAR_SCOPE_FILES = "files"
CHAR_SCOPE_BOTH = "both"
CHAR_SCOPE_COMMENTS = "comments"
FAVORITE_SCOPE_SELECTED_LOCATION = "selected_location"
FAVORITE_SCOPE_ARTIST_FOLDERS = "artist_folders"
# --- Download Status Constants ---
FILE_DOWNLOAD_STATUS_SUCCESS = "success"
FILE_DOWNLOAD_STATUS_SKIPPED = "skipped"
FILE_DOWNLOAD_STATUS_FAILED_RETRYABLE_LATER = "failed_retry_later"
FILE_DOWNLOAD_STATUS_FAILED_PERMANENTLY_THIS_SESSION = "failed_permanent_session"
# --- Threading and Performance ---
MAX_THREADS = 200
RECOMMENDED_MAX_THREADS = 50
SOFT_WARNING_THREAD_THRESHOLD = 40
MAX_FILE_THREADS_PER_POST_OR_WORKER = 10
POST_WORKER_BATCH_THRESHOLD = 30
POST_WORKER_NUM_BATCHES = 4
POST_WORKER_BATCH_DELAY_SECONDS = 2.5
MAX_POST_WORKERS_WHEN_COMMENT_FILTERING = 3
# --- Multipart Download Settings ---
MIN_SIZE_FOR_MULTIPART_DOWNLOAD = 10 * 1024 * 1024 # 10 MB
MAX_PARTS_FOR_MULTIPART_DOWNLOAD = 15
# --- UI and Settings Keys (for QSettings) ---
TOUR_SHOWN_KEY = "neverShowTourAgainV19"
MANGA_FILENAME_STYLE_KEY = "mangaFilenameStyleV1"
SKIP_WORDS_SCOPE_KEY = "skipWordsScopeV1"
ALLOW_MULTIPART_DOWNLOAD_KEY = "allowMultipartDownloadV1"
USE_COOKIE_KEY = "useCookieV1"
COOKIE_TEXT_KEY = "cookieTextV1"
CHAR_FILTER_SCOPE_KEY = "charFilterScopeV1"
THEME_KEY = "currentThemeV2"
SCAN_CONTENT_IMAGES_KEY = "scanContentForImagesV1"
LANGUAGE_KEY = "currentLanguageV1"
DOWNLOAD_LOCATION_KEY = "downloadLocationV1"
RESOLUTION_KEY = "window_resolution"
UI_SCALE_KEY = "ui_scale_factor"
SAVE_CREATOR_JSON_KEY = "saveCreatorJsonProfile"
# --- UI Constants and Identifiers ---
HTML_PREFIX = "<!HTML!>"
LOG_DISPLAY_LINKS = "links"
LOG_DISPLAY_DOWNLOAD_PROGRESS = "download_progress"
# --- Dialog Return Codes ---
CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
# --- File Type Extensions ---
IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
}
VIDEO_EXTENSIONS = {
'.mp4', '.mov', '.mkv', '.webm', '.avi', '.wmv', '.flv', '.mpeg',
'.mpg', '.m4v', '.3gp', '.ogv', '.ts', '.vob'
}
ARCHIVE_EXTENSIONS = {
'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2'
}
AUDIO_EXTENSIONS = {
'.mp3', '.wav', '.aac', '.flac', '.ogg', '.wma', '.m4a', '.opus',
'.aiff', '.ape', '.mid', '.midi'
}
# --- Text Processing Constants ---
MAX_FILENAME_COMPONENT_LENGTH = 150
# Words to ignore when creating folder names from titles
FOLDER_NAME_STOP_WORDS = {
"a", "alone", "am", "an", "and", "at", "be", "by", "com",
"for", "he", "her", "his", "i", "im", "in", "is", "it", "its",
"me", "my", "net", "not", "of", "on", "or", "org", "our",
"s", "she", "so", "the", "their", "they", "this",
"to", "ve", "was", "we", "were", "with", "www", "you", "your",
# add more according to need
}
# Additional words to ignore specifically for creator-level downloads
CREATOR_DOWNLOAD_DEFAULT_FOLDER_IGNORE_WORDS = {
"poll", "cover", "fan-art", "fanart", "requests", "request", "holiday",
"batch", "open", "closed", "winner", "loser", "wip",
"update", "news", "discussion", "question", "stream", "video", "sketchbook",
# Months and days
"jan", "january", "feb", "february", "mar", "march", "apr", "april",
"may", "jun", "june", "jul", "july", "aug", "august", "sep", "september",
"oct", "october", "nov", "november", "dec", "december",
"mon", "monday", "tue", "tuesday", "wed", "wednesday", "thu", "thursday",
"fri", "friday", "sat", "saturday", "sun", "sunday"
# add more according to need
}
# --- Duplicate Handling Modes ---
DUPLICATE_HANDLING_HASH = "hash"
DUPLICATE_HANDLING_KEEP_ALL = "keep_all"

1
src/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# ...existing code...

343
src/core/api_client.py Normal file
View File

@@ -0,0 +1,343 @@
import time
import traceback
from urllib.parse import urlparse
import json # Ensure json is imported
import requests
from ..utils.network_utils import extract_post_info, prepare_cookies_for_request
from ..config.constants import (
STYLE_DATE_POST_TITLE
)
def fetch_posts_paginated(api_url_base, headers, offset, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
"""
Fetches a single page of posts from the API with robust retry logic.
NEW: Requests only essential fields to keep the response size small and reliable.
"""
if cancellation_event and cancellation_event.is_set():
raise RuntimeError("Fetch operation cancelled by user.")
if pause_event and pause_event.is_set():
logger(" Post fetching paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
raise RuntimeError("Fetch operation cancelled by user while paused.")
time.sleep(0.5)
logger(" Post fetching resumed.")
fields_to_request = "id,user,service,title,shared_file,added,published,edited,file,attachments,tags"
paginated_url = f'{api_url_base}?o={offset}&fields={fields_to_request}'
max_retries = 3
retry_delay = 5
for attempt in range(max_retries):
if cancellation_event and cancellation_event.is_set():
raise RuntimeError("Fetch operation cancelled by user during retry loop.")
log_message = f" Fetching post list: {api_url_base}?o={offset} (Page approx. {offset // 50 + 1})"
if attempt > 0:
log_message += f" (Attempt {attempt + 1}/{max_retries})"
logger(log_message)
try:
response = requests.get(paginated_url, headers=headers, timeout=(15, 60), cookies=cookies_dict)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
logger(f" ⚠️ Retryable network error on page fetch (Attempt {attempt + 1}): {e}")
if attempt < max_retries - 1:
delay = retry_delay * (2 ** attempt)
logger(f" Retrying in {delay} seconds...")
time.sleep(delay)
continue
else:
logger(f" ❌ Failed to fetch page after {max_retries} attempts.")
raise RuntimeError(f"Network error fetching offset {offset}")
except json.JSONDecodeError as e:
logger(f" ❌ Failed to decode JSON on page fetch (Attempt {attempt + 1}): {e}")
if attempt < max_retries - 1:
delay = retry_delay * (2 ** attempt)
logger(f" Retrying in {delay} seconds...")
time.sleep(delay)
continue
else:
raise RuntimeError(f"JSONDecodeError fetching offset {offset}")
raise RuntimeError(f"Failed to fetch page {paginated_url} after all attempts.")
def fetch_single_post_data(api_domain, service, user_id, post_id, headers, logger, cookies_dict=None):
"""
--- NEW FUNCTION ---
Fetches the full data, including the 'content' field, for a single post.
"""
post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}"
logger(f" Fetching full content for post ID {post_id}...")
try:
with requests.get(post_api_url, headers=headers, timeout=(15, 300), cookies=cookies_dict, stream=True) as response:
response.raise_for_status()
response_body = b""
for chunk in response.iter_content(chunk_size=8192):
response_body += chunk
full_post_data = json.loads(response_body)
if isinstance(full_post_data, list) and full_post_data:
return full_post_data[0]
return full_post_data
except Exception as e:
logger(f" ❌ Failed to fetch full content for post {post_id}: {e}")
return None
def fetch_post_comments(api_domain, service, user_id, post_id, headers, logger, cancellation_event=None, pause_event=None, cookies_dict=None):
"""Fetches all comments for a specific post."""
if cancellation_event and cancellation_event.is_set():
raise RuntimeError("Comment fetch operation cancelled by user.")
comments_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{post_id}/comments"
logger(f" Fetching comments: {comments_api_url}")
try:
response = requests.get(comments_api_url, headers=headers, timeout=(10, 30), cookies=cookies_dict)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Error fetching comments for post {post_id}: {e}")
except ValueError as e:
raise RuntimeError(f"Error decoding JSON from comments API for post {post_id}: {e}")
def download_from_api(
api_url_input,
logger=print,
start_page=None,
end_page=None,
manga_mode=False,
cancellation_event=None,
pause_event=None,
use_cookie=False,
cookie_text="",
selected_cookie_file=None,
app_base_dir=None,
manga_filename_style_for_sort_check=None,
processed_post_ids=None # --- ADD THIS ARGUMENT ---
):
headers = {
'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json'
}
if processed_post_ids is None:
processed_post_ids = set()
else:
processed_post_ids = set(processed_post_ids)
service, user_id, target_post_id = extract_post_info(api_url_input)
if cancellation_event and cancellation_event.is_set():
logger(" Download_from_api cancelled at start.")
return
parsed_input_url_for_domain = urlparse(api_url_input)
api_domain = parsed_input_url_for_domain.netloc
if not any(d in api_domain.lower() for d in ['kemono.su', 'kemono.party', 'coomer.su', 'coomer.party']):
logger(f"⚠️ Unrecognized domain '{api_domain}' from input URL. Defaulting to kemono.su for API calls.")
api_domain = "kemono.su"
cookies_for_api = None
if use_cookie and app_base_dir:
cookies_for_api = prepare_cookies_for_request(use_cookie, cookie_text, selected_cookie_file, app_base_dir, logger, target_domain=api_domain)
if target_post_id:
if target_post_id in processed_post_ids:
logger(f" Skipping already processed target post ID: {target_post_id}")
return
direct_post_api_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}/post/{target_post_id}"
logger(f" Attempting direct fetch for target post: {direct_post_api_url}")
try:
direct_response = requests.get(direct_post_api_url, headers=headers, timeout=(10, 30), cookies=cookies_for_api)
direct_response.raise_for_status()
direct_post_data = direct_response.json()
if isinstance(direct_post_data, list) and direct_post_data:
direct_post_data = direct_post_data[0]
if isinstance(direct_post_data, dict) and 'post' in direct_post_data and isinstance(direct_post_data['post'], dict):
direct_post_data = direct_post_data['post']
if isinstance(direct_post_data, dict) and direct_post_data.get('id') == target_post_id:
logger(f" ✅ Direct fetch successful for post {target_post_id}.")
yield [direct_post_data]
return
else:
response_type = type(direct_post_data).__name__
response_snippet = str(direct_post_data)[:200]
logger(f" ⚠️ Direct fetch for post {target_post_id} returned unexpected data (Type: {response_type}, Snippet: '{response_snippet}'). Falling back to pagination.")
except requests.exceptions.RequestException as e:
logger(f" ⚠️ Direct fetch failed for post {target_post_id}: {e}. Falling back to pagination.")
except Exception as e:
logger(f" ⚠️ Unexpected error during direct fetch for post {target_post_id}: {e}. Falling back to pagination.")
if not service or not user_id:
logger(f"❌ Invalid URL or could not extract service/user: {api_url_input}")
return
if target_post_id and (start_page or end_page):
logger("⚠️ Page range (start/end page) is ignored when a specific post URL is provided (searching all pages for the post).")
is_manga_mode_fetch_all_and_sort_oldest_first = manga_mode and (manga_filename_style_for_sort_check != STYLE_DATE_POST_TITLE) and not target_post_id
api_base_url = f"https://{api_domain}/api/v1/{service}/user/{user_id}"
page_size = 50
if is_manga_mode_fetch_all_and_sort_oldest_first:
logger(f" Manga Mode (Style: {manga_filename_style_for_sort_check if manga_filename_style_for_sort_check else 'Default'} - Oldest First Sort Active): Fetching all posts to sort by date...")
all_posts_for_manga_mode = []
current_offset_manga = 0
if start_page and start_page > 1:
current_offset_manga = (start_page - 1) * page_size
logger(f" Manga Mode: Starting fetch from page {start_page} (offset {current_offset_manga}).")
elif start_page:
logger(f" Manga Mode: Starting fetch from page 1 (offset 0).")
if end_page:
logger(f" Manga Mode: Will fetch up to page {end_page}.")
while True:
if pause_event and pause_event.is_set():
logger(" Manga mode post fetching paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post fetching cancelled while paused.")
break
time.sleep(0.5)
if not (cancellation_event and cancellation_event.is_set()): logger(" Manga mode post fetching resumed.")
if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post fetching cancelled.")
break
current_page_num_manga = (current_offset_manga // page_size) + 1
if end_page and current_page_num_manga > end_page:
logger(f" Manga Mode: Reached specified end page ({end_page}). Stopping post fetch.")
break
try:
posts_batch_manga = fetch_posts_paginated(api_base_url, headers, current_offset_manga, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
if not isinstance(posts_batch_manga, list):
logger(f"❌ API Error (Manga Mode): Expected list of posts, got {type(posts_batch_manga)}.")
break
if not posts_batch_manga:
logger("✅ Reached end of posts (Manga Mode fetch all).")
if start_page and not end_page and current_page_num_manga < start_page:
logger(f" Manga Mode: No posts found on or after specified start page {start_page}.")
elif end_page and current_page_num_manga <= end_page and not all_posts_for_manga_mode:
logger(f" Manga Mode: No posts found within the specified page range ({start_page or 1}-{end_page}).")
break
all_posts_for_manga_mode.extend(posts_batch_manga)
current_offset_manga += page_size
time.sleep(0.6)
except RuntimeError as e:
if "cancelled by user" in str(e).lower():
logger(f" Manga mode pagination stopped due to cancellation: {e}")
else:
logger(f"{e}\n Aborting manga mode pagination.")
break
except Exception as e:
logger(f"❌ Unexpected error during manga mode fetch: {e}")
traceback.print_exc()
break
if cancellation_event and cancellation_event.is_set(): return
if all_posts_for_manga_mode:
if processed_post_ids:
original_count = len(all_posts_for_manga_mode)
all_posts_for_manga_mode = [post for post in all_posts_for_manga_mode if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(all_posts_for_manga_mode)
if skipped_count > 0:
logger(f" Manga Mode: Skipped {skipped_count} already processed post(s) before sorting.")
logger(f" Manga Mode: Fetched {len(all_posts_for_manga_mode)} total posts. Sorting by publication date (oldest first)...")
def sort_key_tuple(post):
published_date_str = post.get('published')
added_date_str = post.get('added')
post_id_str = post.get('id', "0")
primary_sort_val = "0000-00-00T00:00:00"
if published_date_str:
primary_sort_val = published_date_str
elif added_date_str:
logger(f" ⚠️ Post ID {post_id_str} missing 'published' date, using 'added' date '{added_date_str}' for primary sorting.")
primary_sort_val = added_date_str
else:
logger(f" ⚠️ Post ID {post_id_str} missing both 'published' and 'added' dates. Placing at start of sort (using default earliest date).")
secondary_sort_val = 0
try:
secondary_sort_val = int(post_id_str)
except ValueError:
logger(f" ⚠️ Post ID '{post_id_str}' is not a valid integer for secondary sorting, using 0.")
return (primary_sort_val, secondary_sort_val)
all_posts_for_manga_mode.sort(key=sort_key_tuple)
for i in range(0, len(all_posts_for_manga_mode), page_size):
if cancellation_event and cancellation_event.is_set():
logger(" Manga mode post yielding cancelled.")
break
yield all_posts_for_manga_mode[i:i + page_size]
return
if manga_mode and not target_post_id and (manga_filename_style_for_sort_check == STYLE_DATE_POST_TITLE):
logger(f" Manga Mode (Style: {STYLE_DATE_POST_TITLE}): Processing posts in default API order (newest first).")
current_page_num = 1
current_offset = 0
processed_target_post_flag = False
if start_page and start_page > 1 and not target_post_id:
current_offset = (start_page - 1) * page_size
current_page_num = start_page
logger(f" Starting from page {current_page_num} (calculated offset {current_offset}).")
while True:
if pause_event and pause_event.is_set():
logger(" Post fetching loop paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
logger(" Post fetching loop cancelled while paused.")
break
time.sleep(0.5)
if not (cancellation_event and cancellation_event.is_set()): logger(" Post fetching loop resumed.")
if cancellation_event and cancellation_event.is_set():
logger(" Post fetching loop cancelled.")
break
if target_post_id and processed_target_post_flag:
break
if not target_post_id and end_page and current_page_num > end_page:
logger(f"✅ Reached specified end page ({end_page}) for creator feed. Stopping.")
break
try:
posts_batch = fetch_posts_paginated(api_base_url, headers, current_offset, logger, cancellation_event, pause_event, cookies_dict=cookies_for_api)
if not isinstance(posts_batch, list):
logger(f"❌ API Error: Expected list of posts, got {type(posts_batch)} at page {current_page_num} (offset {current_offset}).")
break
except RuntimeError as e:
if "cancelled by user" in str(e).lower():
logger(f" Pagination stopped due to cancellation: {e}")
else:
logger(f"{e}\n Aborting pagination at page {current_page_num} (offset {current_offset}).")
break
except Exception as e:
logger(f"❌ Unexpected error fetching page {current_page_num} (offset {current_offset}): {e}")
traceback.print_exc()
break
if processed_post_ids:
original_count = len(posts_batch)
posts_batch = [post for post in posts_batch if post.get('id') not in processed_post_ids]
skipped_count = original_count - len(posts_batch)
if skipped_count > 0:
logger(f" Skipped {skipped_count} already processed post(s) from page {current_page_num}.")
if not posts_batch:
if target_post_id and not processed_target_post_flag:
logger(f"❌ Target post {target_post_id} not found after checking all available pages (API returned no more posts at offset {current_offset}).")
elif not target_post_id:
if current_page_num == (start_page or 1):
logger(f"😕 No posts found on the first page checked (page {current_page_num}, offset {current_offset}).")
else:
logger(f"✅ Reached end of posts (no more content from API at offset {current_offset}).")
break
if target_post_id and not processed_target_post_flag:
matching_post = next((p for p in posts_batch if str(p.get('id')) == str(target_post_id)), None)
if matching_post:
logger(f"🎯 Found target post {target_post_id} on page {current_page_num} (offset {current_offset}).")
yield [matching_post]
processed_target_post_flag = True
elif not target_post_id:
yield posts_batch
if processed_target_post_flag:
break
current_offset += page_size
current_page_num += 1
time.sleep(0.6)
if target_post_id and not processed_target_post_flag and not (cancellation_event and cancellation_event.is_set()):
logger(f"❌ Target post {target_post_id} could not be found after checking all relevant pages (final check after loop).")

269
src/core/manager.py Normal file
View File

@@ -0,0 +1,269 @@
import threading
import time
import os
import json
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed, Future
from .api_client import download_from_api
from .workers import PostProcessorWorker, DownloadThread
from ..config.constants import (
STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING,
MAX_THREADS, POST_WORKER_BATCH_THRESHOLD, POST_WORKER_NUM_BATCHES,
POST_WORKER_BATCH_DELAY_SECONDS
)
from ..utils.file_utils import clean_folder_name
class DownloadManager:
"""
Manages the entire download lifecycle, acting as a bridge between the UI
and the backend workers. It handles thread pools, task submission,
and state management for a download session.
"""
def __init__(self, progress_queue):
"""
Initializes the DownloadManager.
Args:
progress_queue (queue.Queue): A thread-safe queue for sending
status updates to the UI.
"""
self.progress_queue = progress_queue
self.thread_pool = None
self.active_futures = []
self.cancellation_event = threading.Event()
self.pause_event = threading.Event()
self.is_running = False
self.total_posts = 0
self.processed_posts = 0
self.total_downloads = 0
self.total_skips = 0
self.all_kept_original_filenames = []
self.creator_profiles_dir = None
self.current_creator_name_for_profile = None
self.current_creator_profile_path = None
def _log(self, message):
"""Puts a progress message into the queue for the UI."""
self.progress_queue.put({'type': 'progress', 'payload': (message,)})
def start_session(self, config, restore_data=None):
"""
Starts a new download session based on the provided configuration.
This is the main entry point called by the UI.
Args:
config (dict): A dictionary containing all settings from the UI.
restore_data (dict, optional): Data from a previous, interrupted session.
"""
if self.is_running:
self._log("❌ Cannot start a new session: A session is already in progress.")
return
creator_profile_data = self._setup_creator_profile(config)
creator_profile_data['settings'] = config
creator_profile_data.setdefault('processed_post_ids', [])
self._save_creator_profile(creator_profile_data)
self._log(f"✅ Loaded/created profile for '{self.current_creator_name_for_profile}'. Settings saved.")
self.is_running = True
self.cancellation_event.clear()
self.pause_event.clear()
self.active_futures.clear()
self.total_posts = 0
self.processed_posts = 0
self.total_downloads = 0
self.total_skips = 0
self.all_kept_original_filenames = []
is_single_post = bool(config.get('target_post_id_from_initial_url'))
use_multithreading = config.get('use_multithreading', True)
is_manga_sequential = config.get('manga_mode_active') and config.get('manga_filename_style') in [STYLE_DATE_BASED, STYLE_POST_TITLE_GLOBAL_NUMBERING]
should_use_multithreading_for_posts = use_multithreading and not is_single_post and not is_manga_sequential
if should_use_multithreading_for_posts:
fetcher_thread = threading.Thread(
target=self._fetch_and_queue_posts_for_pool,
args=(config, restore_data, creator_profile_data), # Add argument here
daemon=True
)
fetcher_thread.start()
else:
self._start_single_threaded_session(config)
def _start_single_threaded_session(self, config):
"""Handles downloads that are best processed by a single worker thread."""
self._log(" Initializing single-threaded download process...")
self.worker_thread = threading.Thread(
target=self._run_single_worker,
args=(config,),
daemon=True
)
self.worker_thread.start()
def _run_single_worker(self, config):
"""Target function for the single-worker thread."""
try:
worker = DownloadThread(config, self.progress_queue)
worker.run() # This is the main blocking call for this thread
except Exception as e:
self._log(f"❌ CRITICAL ERROR in single-worker thread: {e}")
self._log(traceback.format_exc())
finally:
self.is_running = False
def _fetch_and_queue_posts_for_pool(self, config, restore_data):
"""
Fetches all posts from the API and submits them as tasks to a thread pool.
This method runs in its own dedicated thread to avoid blocking.
"""
try:
num_workers = min(config.get('num_threads', 4), MAX_THREADS)
self.thread_pool = ThreadPoolExecutor(max_workers=num_workers, thread_name_prefix='PostWorker_')
session_processed_ids = set(restore_data['processed_post_ids']) if restore_data else set()
profile_processed_ids = set(creator_profile_data.get('processed_post_ids', []))
processed_ids = session_processed_ids.union(profile_processed_ids)
if restore_data:
all_posts = restore_data['all_posts_data']
processed_ids = set(restore_data['processed_post_ids'])
posts_to_process = [p for p in all_posts if p.get('id') not in processed_ids]
self.total_posts = len(all_posts)
self.processed_posts = len(processed_ids)
self._log(f"🔄 Restoring session. {len(posts_to_process)} posts remaining.")
else:
posts_to_process = self._get_all_posts(config)
self.total_posts = len(posts_to_process)
self.processed_posts = 0
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
if not posts_to_process:
self._log("✅ No new posts to process.")
return
for post_data in posts_to_process:
if self.cancellation_event.is_set():
break
worker = PostProcessorWorker(post_data, config, self.progress_queue)
future = self.thread_pool.submit(worker.process)
future.add_done_callback(self._handle_future_result)
self.active_futures.append(future)
except Exception as e:
self._log(f"❌ CRITICAL ERROR in post fetcher thread: {e}")
self._log(traceback.format_exc())
finally:
if self.thread_pool:
self.thread_pool.shutdown(wait=True)
self.is_running = False
self._log("🏁 All processing tasks have completed or been cancelled.")
self.progress_queue.put({
'type': 'finished',
'payload': (self.total_downloads, self.total_skips, self.cancellation_event.is_set(), self.all_kept_original_filenames)
})
def _get_all_posts(self, config):
"""Helper to fetch all posts using the API client."""
all_posts = []
post_generator = download_from_api(
api_url_input=config['api_url'],
logger=self._log,
start_page=config.get('start_page'),
end_page=config.get('end_page'),
manga_mode=config.get('manga_mode_active', False),
cancellation_event=self.cancellation_event,
pause_event=self.pause_event,
use_cookie=config.get('use_cookie', False),
cookie_text=config.get('cookie_text', ''),
selected_cookie_file=config.get('selected_cookie_file'),
app_base_dir=config.get('app_base_dir'),
manga_filename_style_for_sort_check=config.get('manga_filename_style'),
processed_post_ids=config.get('processed_post_ids', [])
)
for batch in post_generator:
all_posts.extend(batch)
return all_posts
def _handle_future_result(self, future: Future):
"""Callback executed when a worker task completes."""
if self.cancellation_event.is_set():
return
with threading.Lock(): # Protect shared counters
self.processed_posts += 1
try:
if future.cancelled():
self._log("⚠️ A post processing task was cancelled.")
self.total_skips += 1
else:
result = future.result()
(dl_count, skip_count, kept_originals,
retryable, permanent, history) = result
self.total_downloads += dl_count
self.total_skips += skip_count
self.all_kept_original_filenames.extend(kept_originals)
if retryable:
self.progress_queue.put({'type': 'retryable_failure', 'payload': (retryable,)})
if permanent:
self.progress_queue.put({'type': 'permanent_failure', 'payload': (permanent,)})
if history:
self.progress_queue.put({'type': 'post_processed_history', 'payload': (history,)})
post_id = history.get('post_id')
if post_id and self.current_creator_profile_path:
profile_data = self._setup_creator_profile({'creator_name_for_profile': self.current_creator_name_for_profile, 'session_file_path': self.session_file_path})
if post_id not in profile_data.get('processed_post_ids', []):
profile_data.setdefault('processed_post_ids', []).append(post_id)
self._save_creator_profile(profile_data)
except Exception as e:
self._log(f"❌ Worker task resulted in an exception: {e}")
self.total_skips += 1 # Count errored posts as skipped
self.progress_queue.put({'type': 'overall_progress', 'payload': (self.total_posts, self.processed_posts)})
def _setup_creator_profile(self, config):
"""Prepares the path and loads data for the current creator's profile."""
self.current_creator_name_for_profile = config.get('creator_name_for_profile')
if not self.current_creator_name_for_profile:
self._log("⚠️ Cannot create creator profile: Name not provided in config.")
return {}
appdata_dir = os.path.dirname(config.get('session_file_path', '.'))
self.creator_profiles_dir = os.path.join(appdata_dir, "creator_profiles")
os.makedirs(self.creator_profiles_dir, exist_ok=True)
safe_filename = clean_folder_name(self.current_creator_name_for_profile) + ".json"
self.current_creator_profile_path = os.path.join(self.creator_profiles_dir, safe_filename)
if os.path.exists(self.current_creator_profile_path):
try:
with open(self.current_creator_profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
self._log(f"❌ Error loading creator profile '{safe_filename}': {e}. Starting fresh.")
return {}
def _save_creator_profile(self, data):
"""Saves the provided data to the current creator's profile file."""
if not self.current_creator_profile_path:
return
try:
temp_path = self.current_creator_profile_path + ".tmp"
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
os.replace(temp_path, self.current_creator_profile_path)
except OSError as e:
self._log(f"❌ Error saving creator profile to '{self.current_creator_profile_path}': {e}")
def cancel_session(self):
"""Cancels the current running session."""
if not self.is_running:
return
self._log("⚠️ Cancellation requested by user...")
self.cancellation_event.set()
if self.thread_pool:
self.thread_pool.shutdown(wait=False, cancel_futures=True)
self.is_running = False

1972
src/core/workers.py Normal file

File diff suppressed because it is too large Load Diff

1
src/i18n/__init__.py Normal file
View File

@@ -0,0 +1 @@
# ...existing code...

3271
src/i18n/translator.py Normal file

File diff suppressed because one or more lines are too long

1
src/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
# ...existing code...

View File

@@ -0,0 +1,164 @@
# --- Standard Library Imports ---
import os
import re
import traceback
import json
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
# --- Third-Party Library Imports ---
import requests
try:
from mega import Mega
MEGA_AVAILABLE = True
except ImportError:
MEGA_AVAILABLE = False
try:
import gdown
GDRIVE_AVAILABLE = True
except ImportError:
GDRIVE_AVAILABLE = False
# --- Helper Functions ---
def _get_filename_from_headers(headers):
"""
Extracts a filename from the Content-Disposition header.
Args:
headers (dict): A dictionary of HTTP response headers.
Returns:
str or None: The extracted filename, or None if not found.
"""
cd = headers.get('content-disposition')
if not cd:
return None
fname_match = re.findall('filename="?([^"]+)"?', cd)
if fname_match:
# Sanitize the filename to prevent directory traversal issues
# and remove invalid characters for most filesystems.
sanitized_name = re.sub(r'[<>:"/\\|?*]', '_', fname_match[0].strip())
return sanitized_name
return None
# --- Main Service Downloader Functions ---
def download_mega_file(mega_url, download_path, logger_func=print):
"""
Downloads a file from a Mega.nz URL.
Handles both public links and links that include a decryption key.
"""
if not MEGA_AVAILABLE:
logger_func("❌ Mega download failed: 'mega.py' library is not installed.")
return
logger_func(f" [Mega] Initializing Mega client...")
try:
mega = Mega()
# Anonymous login is sufficient for public links
m = mega.login()
# --- MODIFIED PART: Added error handling for invalid links ---
try:
file_details = m.find(mega_url)
if file_details is None:
logger_func(f" [Mega] ❌ Download failed. The link appears to be invalid or has been taken down: {mega_url}")
return
except (ValueError, json.JSONDecodeError) as e:
# This block catches the "Expecting value" error
logger_func(f" [Mega] ❌ Download failed. The link is likely invalid or expired. Error: {e}")
return
except Exception as e:
# Catch other potential errors from the mega.py library
logger_func(f" [Mega] ❌ An unexpected error occurred trying to access the link: {e}")
return
# --- END OF MODIFIED PART ---
filename = file_details[1]['a']['n']
logger_func(f" [Mega] File found: '{filename}'. Starting download...")
# Sanitize filename before saving
safe_filename = "".join([c for c in filename if c.isalpha() or c.isdigit() or c in (' ', '.', '_', '-')]).rstrip()
final_path = os.path.join(download_path, safe_filename)
# Check if file already exists
if os.path.exists(final_path):
logger_func(f" [Mega] File '{safe_filename}' already exists. Skipping download.")
return
# Start the download
m.download_url(mega_url, dest_path=download_path, dest_filename=safe_filename)
logger_func(f" [Mega] ✅ Successfully downloaded '{safe_filename}' to '{download_path}'")
except Exception as e:
logger_func(f" [Mega] ❌ An unexpected error occurred during the Mega download process: {e}")
def download_gdrive_file(url, download_path, logger_func=print):
"""Downloads a file from a Google Drive link."""
if not GDRIVE_AVAILABLE:
logger_func("❌ Google Drive download failed: 'gdown' library is not installed.")
return
try:
logger_func(f" [G-Drive] Starting download for: {url}")
# --- MODIFIED PART: Added a message and set quiet=True ---
logger_func(" [G-Drive] Download in progress... This may take some time. Please wait.")
# By setting quiet=True, the progress bar will no longer be printed to the terminal.
output_path = gdown.download(url, output=download_path, quiet=True, fuzzy=True)
# --- END OF MODIFIED PART ---
if output_path and os.path.exists(output_path):
logger_func(f" [G-Drive] ✅ Successfully downloaded to '{output_path}'")
else:
logger_func(f" [G-Drive] ❌ Download failed. The file may have been moved, deleted, or is otherwise inaccessible.")
except Exception as e:
logger_func(f" [G-Drive] ❌ An unexpected error occurred: {e}")
def download_dropbox_file(dropbox_link, download_path=".", logger_func=print):
"""
Downloads a file from a public Dropbox link by modifying the URL for direct download.
Args:
dropbox_link (str): The public Dropbox link to the file.
download_path (str): The directory to save the downloaded file.
logger_func (callable): Function to use for logging.
"""
logger_func(f" [Dropbox] Attempting to download: {dropbox_link}")
# Modify the Dropbox URL to force a direct download instead of showing the preview page.
parsed_url = urlparse(dropbox_link)
query_params = parse_qs(parsed_url.query)
query_params['dl'] = ['1']
new_query = urlencode(query_params, doseq=True)
direct_download_url = urlunparse(parsed_url._replace(query=new_query))
logger_func(f" [Dropbox] Using direct download URL: {direct_download_url}")
try:
if not os.path.exists(download_path):
os.makedirs(download_path, exist_ok=True)
logger_func(f" [Dropbox] Created download directory: {download_path}")
with requests.get(direct_download_url, stream=True, allow_redirects=True, timeout=(10, 300)) as r:
r.raise_for_status()
# Determine filename from headers or URL
filename = _get_filename_from_headers(r.headers) or os.path.basename(parsed_url.path) or "dropbox_file"
full_save_path = os.path.join(download_path, filename)
logger_func(f" [Dropbox] Starting download of '{filename}'...")
# Write file to disk in chunks
with open(full_save_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
logger_func(f" [Dropbox] ✅ Dropbox file downloaded successfully: {full_save_path}")
except Exception as e:
logger_func(f" [Dropbox] ❌ An error occurred during Dropbox download: {e}")
traceback.print_exc(limit=2)
raise

View File

@@ -1,157 +1,147 @@
# --- Standard Library Imports ---
import os
import time
import requests
import hashlib
import http.client
import traceback
import threading
import queue
from concurrent.futures import ThreadPoolExecutor, as_completed
CHUNK_DOWNLOAD_RETRY_DELAY = 2 # Slightly reduced for faster retries if needed
MAX_CHUNK_DOWNLOAD_RETRIES = 1 # Further reduced for quicker fallback if a chunk is problematic
DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256KB for iter_content within a chunk download
# --- Third-Party Library Imports ---
import requests
# --- Module Constants ---
CHUNK_DOWNLOAD_RETRY_DELAY = 2
MAX_CHUNK_DOWNLOAD_RETRIES = 1
DOWNLOAD_CHUNK_SIZE_ITER = 1024 * 256 # 256 KB per iteration chunk
# Flag to indicate if this module and its dependencies are available.
# This was missing and caused the ImportError.
MULTIPART_DOWNLOADER_AVAILABLE = True
def _download_individual_chunk(chunk_url, temp_file_path, start_byte, end_byte, headers,
part_num, total_parts, progress_data, cancellation_event, skip_event, pause_event, global_emit_time_ref, cookies_for_chunk, # Added cookies_for_chunk
logger_func, emitter=None, api_original_filename=None): # Renamed logger, signals to emitter
"""Downloads a single chunk of a file and writes it to the temp file."""
def _download_individual_chunk(
chunk_url, temp_file_path, start_byte, end_byte, headers,
part_num, total_parts, progress_data, cancellation_event,
skip_event, pause_event, global_emit_time_ref, cookies_for_chunk,
logger_func, emitter=None, api_original_filename=None
):
"""
Downloads a single segment (chunk) of a larger file. This function is
intended to be run in a separate thread by a ThreadPoolExecutor.
It handles retries, pauses, and cancellations for its specific chunk.
"""
# --- Pre-download checks for control events ---
if cancellation_event and cancellation_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download cancelled before start.")
return 0, False # bytes_downloaded, success
return 0, False
if skip_event and skip_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event triggered before start.")
return 0, False
if pause_event and pause_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download paused before start...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download cancelled while paused.")
return 0, False
time.sleep(0.2) # Shorter sleep for responsive resume
time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Download resumed.")
# Prepare headers for the specific byte range of this chunk
chunk_headers = headers.copy()
if end_byte != -1 : # For 0-byte files, end_byte might be -1, Range header should not be set or be 0-0
if end_byte != -1:
chunk_headers['Range'] = f"bytes={start_byte}-{end_byte}"
elif start_byte == 0 and end_byte == -1: # Specifically for 0-byte files
pass
bytes_this_chunk = 0
last_speed_calc_time = time.time()
bytes_at_last_speed_calc = 0
# --- Retry Loop ---
for attempt in range(MAX_CHUNK_DOWNLOAD_RETRIES + 1):
if cancellation_event and cancellation_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled during retry loop.")
return bytes_this_chunk, False
if skip_event and skip_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event during retry loop.")
return bytes_this_chunk, False
if pause_event and pause_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused during retry loop...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled while paused in retry loop.")
return bytes_this_chunk, False
time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed from retry loop pause.")
try:
if attempt > 0:
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying download (Attempt {attempt}/{MAX_CHUNK_DOWNLOAD_RETRIES})...")
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Retrying (Attempt {attempt + 1}/{MAX_CHUNK_DOWNLOAD_RETRIES + 1})...")
time.sleep(CHUNK_DOWNLOAD_RETRY_DELAY * (2 ** (attempt - 1)))
last_speed_calc_time = time.time()
bytes_at_last_speed_calc = bytes_this_chunk # Current progress of this chunk
log_msg = f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}"
logger_func(log_msg)
bytes_at_last_speed_calc = bytes_this_chunk
logger_func(f" 🚀 [Chunk {part_num + 1}/{total_parts}] Starting download: bytes {start_byte}-{end_byte if end_byte != -1 else 'EOF'}")
response = requests.get(chunk_url, headers=chunk_headers, timeout=(10, 120), stream=True, cookies=cookies_for_chunk)
response.raise_for_status()
if start_byte == 0 and end_byte == -1 and int(response.headers.get('Content-Length', 0)) == 0:
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Confirmed 0-byte file.")
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = False
progress_data['chunks_status'][part_num]['speed_bps'] = 0
return 0, True
with open(temp_file_path, 'r+b') as f: # Open in read-write binary
# --- Data Writing Loop ---
with open(temp_file_path, 'r+b') as f:
f.seek(start_byte)
for data_segment in response.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE_ITER):
if cancellation_event and cancellation_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled during data iteration.")
return bytes_this_chunk, False
if skip_event and skip_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Skip event during data iteration.")
return bytes_this_chunk, False
if pause_event and pause_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused during data iteration...")
# Handle pausing during the download stream
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Paused...")
while pause_event.is_set():
if cancellation_event and cancellation_event.is_set():
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Cancelled while paused in data iteration.")
return bytes_this_chunk, False
if cancellation_event and cancellation_event.is_set(): return bytes_this_chunk, False
time.sleep(0.2)
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed from data iteration pause.")
logger_func(f" [Chunk {part_num + 1}/{total_parts}] Resumed.")
if data_segment:
f.write(data_segment)
bytes_this_chunk += len(data_segment)
# Update shared progress data structure
with progress_data['lock']:
progress_data['total_downloaded_so_far'] += len(data_segment)
progress_data['chunks_status'][part_num]['downloaded'] = bytes_this_chunk
progress_data['chunks_status'][part_num]['active'] = True
# Calculate and update speed for this chunk
current_time = time.time()
time_delta_speed = current_time - last_speed_calc_time
if time_delta_speed > 0.5: # Calculate speed every 0.5 seconds
time_delta = current_time - last_speed_calc_time
if time_delta > 0.5:
bytes_delta = bytes_this_chunk - bytes_at_last_speed_calc
current_speed_bps = (bytes_delta * 8) / time_delta_speed if time_delta_speed > 0 else 0
current_speed_bps = (bytes_delta * 8) / time_delta if time_delta > 0 else 0
progress_data['chunks_status'][part_num]['speed_bps'] = current_speed_bps
last_speed_calc_time = current_time
bytes_at_last_speed_calc = bytes_this_chunk
if emitter and (current_time - global_emit_time_ref[0] > 0.25): # Max ~4Hz for the whole file
global_emit_time_ref[0] = current_time # Update shared last emit time
status_list_copy = [dict(s) for s in progress_data['chunks_status']] # Make a deep enough copy
bytes_at_last_speed_calc = bytes_this_chunk
# Emit progress signal to the UI via the queue
if emitter and (current_time - global_emit_time_ref[0] > 0.25):
global_emit_time_ref[0] = current_time
status_list_copy = [dict(s) for s in progress_data['chunks_status']]
if isinstance(emitter, queue.Queue):
emitter.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
elif hasattr(emitter, 'file_progress_signal'): # PostProcessorSignals-like
elif hasattr(emitter, 'file_progress_signal'):
emitter.file_progress_signal.emit(api_original_filename, status_list_copy)
# If we reach here, the download for this chunk was successful
return bytes_this_chunk, True
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, http.client.IncompleteRead) as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Retryable error: {e}")
if attempt == MAX_CHUNK_DOWNLOAD_RETRIES:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Failed after {MAX_CHUNK_DOWNLOAD_RETRIES} retries.")
return bytes_this_chunk, False
except requests.exceptions.RequestException as e: # Includes 4xx/5xx errors after raise_for_status
except requests.exceptions.RequestException as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Non-retryable error: {e}")
return bytes_this_chunk, False
return bytes_this_chunk, False # Break loop on non-retryable errors
except Exception as e:
logger_func(f" ❌ [Chunk {part_num + 1}/{total_parts}] Unexpected error: {e}\n{traceback.format_exc(limit=1)}")
return bytes_this_chunk, False
with progress_data['lock']:
progress_data['chunks_status'][part_num]['active'] = False
progress_data['chunks_status'][part_num]['speed_bps'] = 0
return bytes_this_chunk, False # Should be unreachable
return bytes_this_chunk, False
def download_file_in_parts(file_url, save_path, total_size, num_parts, headers, api_original_filename,
emitter_for_multipart, cookies_for_chunk_session, # Added cookies_for_chunk_session
emitter_for_multipart, cookies_for_chunk_session,
cancellation_event, skip_event, logger_func, pause_event):
"""
Downloads a file in multiple parts concurrently.
Returns: (download_successful_flag, downloaded_bytes, calculated_file_hash, temp_file_handle_or_None)
The temp_file_handle will be an open read-binary file handle to the .part file if successful, otherwise None.
It is the responsibility of the caller to close this handle and rename/delete the .part file.
"""
logger_func(f"⬇️ Initializing Multi-part Download ({num_parts} parts) for: '{api_original_filename}' (Size: {total_size / (1024*1024):.2f} MB)")
temp_file_path = save_path + ".part"
try:
with open(temp_file_path, 'wb') as f_temp:
if total_size > 0:
f_temp.truncate(total_size) # Pre-allocate space
f_temp.truncate(total_size)
except IOError as e:
logger_func(f" ❌ Error creating/truncating temp file '{temp_file_path}': {e}")
return False, 0, None, None
@@ -161,14 +151,14 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers,
for i in range(num_parts):
start = i * chunk_size_calc
end = start + chunk_size_calc - 1 if i < num_parts - 1 else total_size - 1
if start <= end: # Valid range
if start <= end:
chunks_ranges.append((start, end))
elif total_size == 0 and i == 0: # Special case for 0-byte file
chunks_ranges.append((0, -1)) # Indicates 0-byte file, download 0 bytes from offset 0
elif total_size == 0 and i == 0:
chunks_ranges.append((0, -1))
chunk_actual_sizes = []
chunk_actual_sizes = []
for start, end in chunks_ranges:
if end == -1 and start == 0: # 0-byte file
if end == -1 and start == 0:
chunk_actual_sizes.append(0)
else:
chunk_actual_sizes.append(end - start + 1)
@@ -179,19 +169,19 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers,
return False, 0, None, None
progress_data = {
'total_file_size': total_size, # Overall file size for reference
'total_downloaded_so_far': 0, # New key for overall progress
'chunks_status': [ # Status for each chunk
'total_file_size': total_size,
'total_downloaded_so_far': 0,
'chunks_status': [
{'id': i, 'downloaded': 0, 'total': chunk_actual_sizes[i] if i < len(chunk_actual_sizes) else 0, 'active': False, 'speed_bps': 0.0}
for i in range(num_parts)
],
'lock': threading.Lock(),
'last_global_emit_time': [time.time()] # Shared mutable for global throttling timestamp
'last_global_emit_time': [time.time()]
}
chunk_futures = []
all_chunks_successful = True
total_bytes_from_chunks = 0 # Still useful to verify total downloaded against file size
total_bytes_from_chunks = 0
with ThreadPoolExecutor(max_workers=num_parts, thread_name_prefix=f"MPChunk_{api_original_filename[:10]}_") as chunk_pool:
for i, (start, end) in enumerate(chunks_ranges):
@@ -219,14 +209,14 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers,
status_list_copy = [dict(s) for s in progress_data['chunks_status']]
if isinstance(emitter_for_multipart, queue.Queue):
emitter_for_multipart.put({'type': 'file_progress', 'payload': (api_original_filename, status_list_copy)})
elif hasattr(emitter_for_multipart, 'file_progress_signal'): # PostProcessorSignals-like
elif hasattr(emitter_for_multipart, 'file_progress_signal'):
emitter_for_multipart.file_progress_signal.emit(api_original_filename, status_list_copy)
if all_chunks_successful and (total_bytes_from_chunks == total_size or total_size == 0):
logger_func(f" ✅ Multi-part download successful for '{api_original_filename}'. Total bytes: {total_bytes_from_chunks}")
md5_hasher = hashlib.md5()
with open(temp_file_path, 'rb') as f_hash:
for buf in iter(lambda: f_hash.read(4096*10), b''): # Read in larger buffers for hashing
for buf in iter(lambda: f_hash.read(4096*10), b''):
md5_hasher.update(buf)
calculated_hash = md5_hasher.hexdigest()
return True, total_bytes_from_chunks, calculated_hash, open(temp_file_path, 'rb')
@@ -235,4 +225,4 @@ def download_file_in_parts(file_url, save_path, total_size, num_parts, headers,
if os.path.exists(temp_file_path):
try: os.remove(temp_file_path)
except OSError as e: logger_func(f" Failed to remove temp part file '{temp_file_path}': {e}")
return False, total_bytes_from_chunks, None, None
return False, total_bytes_from_chunks, None, None

1
src/ui/__init__.py Normal file
View File

@@ -0,0 +1 @@
# ...existing code...

40
src/ui/assets.py Normal file
View File

@@ -0,0 +1,40 @@
import os
import sys
from PyQt5.QtGui import QIcon
_app_icon_cache = None
def get_app_icon_object():
"""
Loads and caches the application icon from the assets folder.
This function is now centralized to prevent circular imports.
Returns:
QIcon: The application icon object.
"""
global _app_icon_cache
if _app_icon_cache and not _app_icon_cache.isNull():
return _app_icon_cache
app_base_dir = ""
if getattr(sys, 'frozen', False):
app_base_dir = os.path.dirname(sys.executable)
else:
app_base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
icon_path = os.path.join(app_base_dir, 'assets', 'Kemono.ico')
if os.path.exists(icon_path):
_app_icon_cache = QIcon(icon_path)
else:
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
fallback_icon_path = os.path.join(sys._MEIPASS, 'assets', 'Kemono.ico')
if os.path.exists(fallback_icon_path):
_app_icon_cache = QIcon(fallback_icon_path)
return _app_icon_cache
print(f"Warning: Application icon not found at {icon_path}")
_app_icon_cache = QIcon() # Return an empty icon as a fallback
return _app_icon_cache

View File

@@ -0,0 +1,157 @@
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout
)
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
CONFIRM_ADD_ALL_ACCEPTED = 1
CONFIRM_ADD_ALL_SKIP_ADDING = 2
CONFIRM_ADD_ALL_CANCEL_DOWNLOAD = 3
class ConfirmAddAllDialog(QDialog):
"""
A dialog to confirm adding multiple new character/series names to Known.txt.
It appears when the user provides filter names that are not already known,
allowing them to persist these names for future use.
"""
def __init__(self, new_filter_objects_list, parent_app, parent=None):
"""
Initializes the dialog.
Args:
new_filter_objects_list (list): A list of filter objects (dicts) to propose adding.
parent_app (DownloaderApp): A reference to the main application window for theming and translations.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app
self.setModal(True)
self.new_filter_objects_list = new_filter_objects_list
self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w, base_min_h = 480, 350
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
self.info_label = QLabel()
self.info_label.setWordWrap(True)
main_layout.addWidget(self.info_label)
self.names_list_widget = QListWidget()
self._populate_list()
main_layout.addWidget(self.names_list_widget)
selection_buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items)
selection_buttons_layout.addWidget(self.select_all_button)
self.deselect_all_button = QPushButton()
self.deselect_all_button.clicked.connect(self._deselect_all_items)
selection_buttons_layout.addWidget(self.deselect_all_button)
selection_buttons_layout.addStretch()
main_layout.addLayout(selection_buttons_layout)
buttons_layout = QHBoxLayout()
self.add_selected_button = QPushButton()
self.add_selected_button.clicked.connect(self._accept_add_selected)
self.add_selected_button.setDefault(True)
buttons_layout.addWidget(self.add_selected_button)
self.skip_adding_button = QPushButton()
self.skip_adding_button.clicked.connect(self._reject_skip_adding)
buttons_layout.addWidget(self.skip_adding_button)
buttons_layout.addStretch()
self.cancel_download_button = QPushButton()
self.cancel_download_button.clicked.connect(self._reject_cancel_download)
buttons_layout.addWidget(self.cancel_download_button)
main_layout.addLayout(buttons_layout)
def _populate_list(self):
"""Populates the list widget with the new names to be confirmed."""
for filter_obj in self.new_filter_objects_list:
item_text = filter_obj["name"]
list_item = QListWidgetItem(item_text)
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
list_item.setCheckState(Qt.Checked)
list_item.setData(Qt.UserRole, filter_obj)
self.names_list_widget.addItem(list_item)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("confirm_add_all_dialog_title", "Confirm Adding New Names"))
self.info_label.setText(self._tr("confirm_add_all_info_label", "The following new names/groups..."))
self.select_all_button.setText(self._tr("confirm_add_all_select_all_button", "Select All"))
self.deselect_all_button.setText(self._tr("confirm_add_all_deselect_all_button", "Deselect All"))
self.add_selected_button.setText(self._tr("confirm_add_all_add_selected_button", "Add Selected to Known.txt"))
self.skip_adding_button.setText(self._tr("confirm_add_all_skip_adding_button", "Skip Adding These"))
self.cancel_download_button.setText(self._tr("confirm_add_all_cancel_download_button", "Cancel Download"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme())
def _select_all_items(self):
"""Checks all items in the list."""
for i in range(self.names_list_widget.count()):
self.names_list_widget.item(i).setCheckState(Qt.Checked)
def _deselect_all_items(self):
"""Unchecks all items in the list."""
for i in range(self.names_list_widget.count()):
self.names_list_widget.item(i).setCheckState(Qt.Unchecked)
def _accept_add_selected(self):
"""Sets the user choice to the list of selected items and accepts the dialog."""
selected_objects = []
for i in range(self.names_list_widget.count()):
item = self.names_list_widget.item(i)
if item.checkState() == Qt.Checked:
filter_obj = item.data(Qt.UserRole)
if filter_obj:
selected_objects.append(filter_obj)
self.user_choice = selected_objects
self.accept()
def _reject_skip_adding(self):
"""Sets the user choice to skip adding and rejects the dialog."""
self.user_choice = CONFIRM_ADD_ALL_SKIP_ADDING
self.reject()
def _reject_cancel_download(self):
"""Sets the user choice to cancel the entire download and rejects the dialog."""
self.user_choice = CONFIRM_ADD_ALL_CANCEL_DOWNLOAD
self.reject()
def exec_(self):
"""
Overrides the default exec_ to handle the return value logic, ensuring a
sensible default if no items are selected but the "Add" button is clicked.
"""
super().exec_()
if isinstance(self.user_choice, list) and not self.user_choice:
return CONFIRM_ADD_ALL_SKIP_ADDING
return self.user_choice

View File

@@ -0,0 +1,135 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class CookieHelpDialog(QDialog):
"""
A dialog to explain how to get a cookies.txt file.
It can be displayed as a simple informational popup or as a modal choice
when cookies are required but not found.
"""
# Constants to define the user's choice from the dialog
CHOICE_PROCEED_WITHOUT_COOKIES = 1
CHOICE_CANCEL_DOWNLOAD = 2
CHOICE_OK_INFO_ONLY = 3
def __init__(self, parent_app, parent=None, offer_download_without_option=False):
"""
Initializes the dialog.
Args:
parent_app (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
offer_download_without_option (bool): If True, shows buttons to
"Download without Cookies" and "Cancel Download". If False,
shows only an "OK" button for informational purposes.
"""
super().__init__(parent)
self.parent_app = parent_app
self.setModal(True)
self.offer_download_without_option = offer_download_without_option
self.user_choice = None
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
self.setMinimumWidth(500)
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
self.info_label = QLabel()
self.info_label.setTextFormat(Qt.RichText)
self.info_label.setOpenExternalLinks(True)
self.info_label.setWordWrap(True)
main_layout.addWidget(self.info_label)
button_layout = QHBoxLayout()
button_layout.addStretch(1)
if self.offer_download_without_option:
# Add buttons for making a choice
self.download_without_button = QPushButton()
self.download_without_button.clicked.connect(self._proceed_without_cookies)
button_layout.addWidget(self.download_without_button)
self.cancel_button = QPushButton()
self.cancel_button.clicked.connect(self._cancel_download)
button_layout.addWidget(self.cancel_button)
else:
# Add a simple OK button for informational display
self.ok_button = QPushButton()
self.ok_button.clicked.connect(self._ok_info_only)
button_layout.addWidget(self.ok_button)
main_layout.addLayout(button_layout)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("cookie_help_dialog_title", "Cookie File Instructions"))
instruction_html = f"""
{self._tr("cookie_help_instruction_intro", "<p>To use cookies...</p>")}
{self._tr("cookie_help_how_to_get_title", "<p><b>How to get cookies.txt:</b></p>")}
<ol>
{self._tr("cookie_help_step1_extension_intro", "<li>Install extension...</li>")}
{self._tr("cookie_help_step2_login", "<li>Go to website...</li>")}
{self._tr("cookie_help_step3_click_icon", "<li>Click icon...</li>")}
{self._tr("cookie_help_step4_export", "<li>Click export...</li>")}
{self._tr("cookie_help_step5_save_file", "<li>Save file...</li>")}
{self._tr("cookie_help_step6_app_intro", "<li>In this application:<ul>")}
{self._tr("cookie_help_step6a_checkbox", "<li>Ensure checkbox...</li>")}
{self._tr("cookie_help_step6b_browse", "<li>Click browse...</li>")}
{self._tr("cookie_help_step6c_select", "<li>Select file...</li></ul></li>")}
</ol>
{self._tr("cookie_help_alternative_paste", "<p>Alternatively, paste...</p>")}
"""
self.info_label.setText(instruction_html)
if self.offer_download_without_option:
self.download_without_button.setText(self._tr("cookie_help_proceed_without_button", "Download without Cookies"))
self.cancel_button.setText(self._tr("cookie_help_cancel_download_button", "Cancel Download"))
else:
self.ok_button.setText(self._tr("ok_button", "OK"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'get_dark_theme') and self.parent_app.current_theme == "dark":
self.setStyleSheet(self.parent_app.get_dark_theme())
def _proceed_without_cookies(self):
"""Handles the user choice to proceed without using cookies."""
self.user_choice = self.CHOICE_PROCEED_WITHOUT_COOKIES
self.accept()
def _cancel_download(self):
"""Handles the user choice to cancel the download."""
self.user_choice = self.CHOICE_CANCEL_DOWNLOAD
self.reject()
def _ok_info_only(self):
"""Handles the acknowledgment when the dialog is purely informational."""
self.user_choice = self.CHOICE_OK_INFO_ONLY
self.accept()

View File

@@ -0,0 +1,153 @@
from collections import defaultdict
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView
)
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class DownloadExtractedLinksDialog(QDialog):
"""
A dialog to select and initiate the download for extracted, supported links
from external cloud services like Mega, Google Drive, and Dropbox.
"""
download_requested = pyqtSignal(list)
def __init__(self, links_data, parent_app, parent=None):
"""
Initializes the dialog.
Args:
links_data (list): A list of dictionaries, each containing info about an extracted link.
parent_app (DownloaderApp): A reference to the main application window for theming and translations.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.links_data = links_data
self.parent_app = parent_app
app_icon = get_app_icon_object()
if not app_icon.isNull():
self.setWindowIcon(app_icon)
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
base_width, base_height = 600, 450
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
layout = QVBoxLayout(self)
self.main_info_label = QLabel()
self.main_info_label.setAlignment(Qt.AlignHCenter | Qt.AlignTop)
self.main_info_label.setWordWrap(True)
layout.addWidget(self.main_info_label)
self.links_list_widget = QListWidget()
self.links_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
self._populate_list()
layout.addWidget(self.links_list_widget)
button_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Checked))
button_layout.addWidget(self.select_all_button)
self.deselect_all_button = QPushButton()
self.deselect_all_button.clicked.connect(lambda: self._set_all_items_checked(Qt.Unchecked))
button_layout.addWidget(self.deselect_all_button)
button_layout.addStretch()
self.download_button = QPushButton()
self.download_button.clicked.connect(self._handle_download_selected)
self.download_button.setDefault(True)
button_layout.addWidget(self.download_button)
self.cancel_button = QPushButton()
self.cancel_button.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_button)
layout.addLayout(button_layout)
def _populate_list(self):
"""Populates the list widget with the provided links, grouped by post title."""
grouped_links = defaultdict(list)
for link_info_item in self.links_data:
post_title_for_group = link_info_item.get('title', 'Untitled Post')
grouped_links[post_title_for_group].append(link_info_item)
sorted_post_titles = sorted(grouped_links.keys(), key=lambda x: x.lower())
for post_title_key in sorted_post_titles:
header_item = QListWidgetItem(f"{post_title_key}")
header_item.setFlags(Qt.NoItemFlags)
font = header_item.font()
font.setBold(True)
font.setPointSize(font.pointSize() + 1)
header_item.setFont(font)
self.links_list_widget.addItem(header_item)
for link_info_data in grouped_links[post_title_key]:
platform_display = link_info_data.get('platform', 'unknown').upper()
display_text = f" [{platform_display}] {link_info_data['link_text']} ({link_info_data['url']})"
item = QListWidgetItem(display_text)
item.setData(Qt.UserRole, link_info_data)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
self.links_list_widget.addItem(item)
def _tr(self, key, default_text=""):
"""Helper to get translation based on current app language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("download_external_links_dialog_title", "Download Selected External Links"))
self.main_info_label.setText(self._tr("download_external_links_dialog_main_label", "Found {count} supported link(s)...").format(count=len(self.links_data)))
self.select_all_button.setText(self._tr("select_all_button_text", "Select All"))
self.deselect_all_button.setText(self._tr("deselect_all_button_text", "Deselect All"))
self.download_button.setText(self._tr("download_selected_button_text", "Download Selected"))
self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
is_dark_theme = self.parent_app and self.parent_app.current_theme == "dark"
if is_dark_theme:
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("")
header_color = Qt.cyan if is_dark_theme else Qt.blue
for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i)
if not item.flags() & Qt.ItemIsUserCheckable:
item.setForeground(header_color)
def _set_all_items_checked(self, check_state):
"""Sets the checked state for all checkable items in the list."""
for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i)
if item.flags() & Qt.ItemIsUserCheckable:
item.setCheckState(check_state)
def _handle_download_selected(self):
"""Gathers selected links and emits the download_requested signal."""
selected_links = []
for i in range(self.links_list_widget.count()):
item = self.links_list_widget.item(i)
if item.flags() & Qt.ItemIsUserCheckable and item.checkState() == Qt.Checked and item.data(Qt.UserRole) is not None:
selected_links.append(item.data(Qt.UserRole))
if selected_links:
self.download_requested.emit(selected_links)
self.accept()
else:
QMessageBox.information(
self,
self._tr("no_selection_title", "No Selection"),
self._tr("no_selection_message_links", "Please select at least one link to download.")
)

View File

@@ -0,0 +1,223 @@
import os
import time
import json
from PyQt5.QtCore import Qt, QStandardPaths, QTimer
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QScrollArea,
QPushButton, QVBoxLayout, QSplitter, QWidget, QGroupBox,
QFileDialog, QMessageBox
)
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class DownloadHistoryDialog (QDialog ):
"""Dialog to display download history."""
def __init__ (self ,last_3_downloaded_entries ,first_processed_entries ,parent_app ,parent =None ):
super ().__init__ (parent )
self .parent_app =parent_app
self .last_3_downloaded_entries =last_3_downloaded_entries
self .first_processed_entries =first_processed_entries
self .setModal (True )
self._apply_theme()
creator_name_cache = getattr(parent_app, 'creator_name_cache', None)
if creator_name_cache:
for entry in self.last_3_downloaded_entries:
if not entry.get('creator_display_name'):
service = entry.get('service', '').lower()
user_id = str(entry.get('user_id', ''))
key = (service, user_id)
entry['creator_display_name'] = creator_name_cache.get(key, entry.get('folder_context_name', 'Unknown Creator/Series'))
for entry in self.first_processed_entries:
if not entry.get('creator_name'):
service = entry.get('service', '').lower()
user_id = str(entry.get('user_id', ''))
key = (service, user_id)
entry['creator_name'] = creator_name_cache.get(key, entry.get('user_id', 'Unknown'))
app_icon =get_app_icon_object ()
if not app_icon .isNull ():
self .setWindowIcon (app_icon )
screen_height =QApplication .primaryScreen ().availableGeometry ().height ()if QApplication .primaryScreen ()else 768
scale_factor =screen_height /768.0
base_min_w ,base_min_h =600 ,450
scaled_min_w =int (base_min_w *1.5 *scale_factor )
scaled_min_h =int (base_min_h *scale_factor )
self .setMinimumSize (scaled_min_w ,scaled_min_h )
self .setWindowTitle (self ._tr ("download_history_dialog_title_combined","Download History"))
dialog_layout =QVBoxLayout (self )
self .setLayout (dialog_layout )
self .main_splitter =QSplitter (Qt .Horizontal )
dialog_layout .addWidget (self .main_splitter )
left_pane_widget =QWidget ()
left_layout =QVBoxLayout (left_pane_widget )
left_header_label =QLabel (self ._tr ("history_last_downloaded_header","Last 3 Files Downloaded:"))
left_header_label .setAlignment (Qt .AlignCenter )
left_layout .addWidget (left_header_label )
left_scroll_area =QScrollArea ()
left_scroll_area .setWidgetResizable (True )
left_scroll_content_widget =QWidget ()
left_scroll_layout =QVBoxLayout (left_scroll_content_widget )
if not self .last_3_downloaded_entries :
no_left_history_label =QLabel (self ._tr ("no_download_history_header","No Downloads Yet"))
no_left_history_label .setAlignment (Qt .AlignCenter )
left_scroll_layout .addWidget (no_left_history_label )
else :
for entry in self .last_3_downloaded_entries :
group_box =QGroupBox (f"{self ._tr ('history_file_label','File:')} {entry .get ('disk_filename','N/A')}")
group_layout =QVBoxLayout (group_box )
details_text =(
f"<b>{self ._tr ('history_from_post_label','From Post:')}</b> {entry .get ('post_title','N/A')} (ID: {entry .get ('post_id','N/A')})<br>"
f"<b>{self ._tr ('history_creator_series_label','Creator/Series:')}</b> {entry .get ('creator_display_name','N/A')}<br>"
f"<b>{self ._tr ('history_post_uploaded_label','Post Uploaded:')}</b> {entry .get ('upload_date_str','N/A')}<br>"
f"<b>{self ._tr ('history_file_downloaded_label','File Downloaded:')}</b> {time .strftime ('%Y-%m-%d %H:%M:%S',time .localtime (entry .get ('download_timestamp',0 )))}<br>"
f"<b>{self ._tr ('history_saved_in_folder_label','Saved In Folder:')}</b> {entry .get ('download_path','N/A')}"
)
details_label =QLabel (details_text )
details_label .setWordWrap (True )
details_label .setTextFormat (Qt .RichText )
group_layout .addWidget (details_label )
left_scroll_layout .addWidget (group_box )
left_scroll_area .setWidget (left_scroll_content_widget )
left_layout .addWidget (left_scroll_area )
self .main_splitter .addWidget (left_pane_widget )
right_pane_widget =QWidget ()
right_layout =QVBoxLayout (right_pane_widget )
right_header_label =QLabel (self ._tr ("first_files_processed_header","First {count} Posts Processed This Session:").format (count =len (self .first_processed_entries )))
right_header_label .setAlignment (Qt .AlignCenter )
right_layout .addWidget (right_header_label )
right_scroll_area =QScrollArea ()
right_scroll_area .setWidgetResizable (True )
right_scroll_content_widget =QWidget ()
right_scroll_layout =QVBoxLayout (right_scroll_content_widget )
if not self .first_processed_entries :
no_right_history_label =QLabel (self ._tr ("no_processed_history_header","No Posts Processed Yet"))
no_right_history_label .setAlignment (Qt .AlignCenter )
right_scroll_layout .addWidget (no_right_history_label )
else :
for entry in self .first_processed_entries :
group_box =QGroupBox (f"{self ._tr ('history_post_label','Post:')} {entry .get ('post_title','N/A')} (ID: {entry .get ('post_id','N/A')})")
group_layout =QVBoxLayout (group_box )
details_text =(
f"<b>{self ._tr ('history_creator_label','Creator:')}</b> {entry .get ('creator_name','N/A')}<br>"
f"<b>{self ._tr ('history_top_file_label','Top File:')}</b> {entry .get ('top_file_name','N/A')}<br>"
f"<b>{self ._tr ('history_num_files_label','Num Files in Post:')}</b> {entry .get ('num_files',0 )}<br>"
f"<b>{self ._tr ('history_post_uploaded_label','Post Uploaded:')}</b> {entry .get ('upload_date_str','N/A')}<br>"
f"<b>{self ._tr ('history_processed_on_label','Processed On:')}</b> {time .strftime ('%Y-%m-%d %H:%M:%S',time .localtime (entry .get ('download_date_timestamp',0 )))}<br>"
f"<b>{self ._tr ('history_saved_to_folder_label','Saved To Folder:')}</b> {entry .get ('download_location','N/A')}"
)
details_label =QLabel (details_text )
details_label .setWordWrap (True )
details_label .setTextFormat (Qt .RichText )
group_layout .addWidget (details_label )
right_scroll_layout .addWidget (group_box )
right_scroll_area .setWidget (right_scroll_content_widget )
right_layout .addWidget (right_scroll_area )
self .main_splitter .addWidget (right_pane_widget )
QTimer .singleShot (0 ,lambda :self .main_splitter .setSizes ([self .width ()//2 ,self .width ()//2 ]))
bottom_button_layout =QHBoxLayout ()
self .save_history_button =QPushButton (self ._tr ("history_save_button_text","Save History to .txt"))
self .save_history_button .clicked .connect (self ._save_history_to_txt )
bottom_button_layout .addStretch (1 )
bottom_button_layout .addWidget (self .save_history_button )
dialog_layout .addLayout (bottom_button_layout )
if self .parent_app and hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
self .setStyleSheet (self .parent_app .get_dark_theme ())
def _tr (self ,key ,default_text =""):
if callable (get_translation )and self .parent_app :
return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }")
def _save_history_to_txt (self ):
if not self .last_3_downloaded_entries and not self .first_processed_entries :
QMessageBox .information (self ,self ._tr ("no_download_history_header","No Downloads Yet"),
self ._tr ("history_nothing_to_save_message","There is no history to save."))
return
main_download_dir =self .parent_app .dir_input .text ().strip ()
default_save_dir =""
if main_download_dir and os .path .isdir (main_download_dir ):
default_save_dir =main_download_dir
else :
fallback_dir =QStandardPaths .writableLocation (QStandardPaths .DocumentsLocation )
if fallback_dir and os .path .isdir (fallback_dir ):
default_save_dir =fallback_dir
else :
default_save_dir =self .parent_app .app_base_dir
default_filepath =os .path .join (default_save_dir ,"download_history.txt")
filepath ,_ =QFileDialog .getSaveFileName (
self ,self ._tr ("history_save_dialog_title","Save Download History"),
default_filepath ,"Text Files (*.txt);;All Files (*)"
)
if not filepath :
return
history_content =[]
history_content .append (f"{self ._tr ('history_last_downloaded_header','Last 3 Files Downloaded:')}\n")
if self .last_3_downloaded_entries :
for entry in self .last_3_downloaded_entries :
history_content .append (f" {self ._tr ('history_file_label','File:')} {entry .get ('disk_filename','N/A')}")
history_content .append (f" {self ._tr ('history_from_post_label','From Post:')} {entry .get ('post_title','N/A')} (ID: {entry .get ('post_id','N/A')})")
history_content .append (f" {self ._tr ('history_creator_series_label','Creator/Series:')} {entry .get ('creator_display_name','N/A')}")
history_content .append (f" {self ._tr ('history_post_uploaded_label','Post Uploaded:')} {entry .get ('upload_date_str','N/A')}")
history_content .append (f" {self ._tr ('history_file_downloaded_label','File Downloaded:')} {time .strftime ('%Y-%m-%d %H:%M:%S',time .localtime (entry .get ('download_timestamp',0 )))}")
history_content .append (f" {self ._tr ('history_saved_in_folder_label','Saved In Folder:')} {entry .get ('download_path','N/A')}\n")
else :
history_content .append (f" ({self ._tr ('no_download_history_header','No Downloads Yet')})\n")
history_content .append (f"\n{self ._tr ('first_files_processed_header','First {count} Posts Processed This Session:').format (count =len (self .first_processed_entries ))}\n")
if self .first_processed_entries :
for entry in self .first_processed_entries :
history_content .append (f" {self ._tr ('history_post_label','Post:')} {entry .get ('post_title','N/A')} (ID: {entry .get ('post_id','N/A')})")
history_content .append (f" {self ._tr ('history_creator_label','Creator:')} {entry .get ('creator_name','N/A')}")
history_content .append (f" {self ._tr ('history_top_file_label','Top File:')} {entry .get ('top_file_name','N/A')}")
history_content .append (f" {self ._tr ('history_num_files_label','Num Files in Post:')} {entry .get ('num_files',0 )}")
history_content .append (f" {self ._tr ('history_post_uploaded_label','Post Uploaded:')} {entry .get ('upload_date_str','N/A')}")
history_content .append (f" {self ._tr ('history_processed_on_label','Processed On:')} {time .strftime ('%Y-%m-%d %H:%M:%S',time .localtime (entry .get ('download_date_timestamp',0 )))}")
history_content .append (f" {self ._tr ('history_saved_to_folder_label','Saved To Folder:')} {entry .get ('download_location','N/A')}\n")
else :
history_content .append (f" ({self ._tr ('no_processed_history_header','No Posts Processed Yet')})\n")
try :
with open (filepath ,'w',encoding ='utf-8')as f :
f .write ("\n".join (history_content ))
QMessageBox .information (self ,self ._tr ("history_export_success_title","History Export Successful"),
self ._tr ("history_export_success_message","Successfully exported download history to:\n{filepath}").format (filepath =filepath ))
except Exception as e :
QMessageBox .critical (self ,self ._tr ("history_export_error_title","History Export Error"),
self ._tr ("history_export_error_message","Could not export download history: {error}").format (error =str (e )))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import pyqtSignal, Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QAbstractItemView, QFileDialog
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..assets import get_app_icon_object
# Corrected Import: The filename uses PascalCase.
from .ExportOptionsDialog import ExportOptionsDialog
from ...utils.resolution import get_dark_theme
class ErrorFilesDialog(QDialog):
"""
Dialog to display files that were skipped due to errors and
allows the user to retry downloading them or export the list of URLs.
"""
# Signal emitted with a list of file info dictionaries to retry
retry_selected_signal = pyqtSignal(list)
def __init__(self, error_files_info_list, parent_app, parent=None):
"""
Initializes the dialog.
Args:
error_files_info_list (list): A list of dictionaries, each containing
info about a failed file.
parent_app (DownloaderApp): A reference to the main application window
for theming and translations.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app
self.setModal(True)
self.error_files = error_files_info_list
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# --- START OF FIX ---
# Get the user-defined scale factor from the parent application.
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Define base dimensions and apply the correct scale factor.
base_width, base_height = 550, 400
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
self.info_label = QLabel()
self.info_label.setWordWrap(True)
main_layout.addWidget(self.info_label)
if self.error_files:
self.files_list_widget = QListWidget()
self.files_list_widget.setSelectionMode(QAbstractItemView.NoSelection)
self._populate_list()
main_layout.addWidget(self.files_list_widget)
# --- Control Buttons ---
buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items)
buttons_layout.addWidget(self.select_all_button)
self.retry_button = QPushButton()
self.retry_button.clicked.connect(self._handle_retry_selected)
buttons_layout.addWidget(self.retry_button)
self.export_button = QPushButton()
self.export_button.clicked.connect(self._handle_export_errors_to_txt)
buttons_layout.addWidget(self.export_button)
buttons_layout.addStretch(1)
self.ok_button = QPushButton()
self.ok_button.clicked.connect(self.accept)
self.ok_button.setDefault(True)
buttons_layout.addWidget(self.ok_button)
main_layout.addLayout(buttons_layout)
# Enable/disable buttons based on whether there are errors
has_errors = bool(self.error_files)
self.select_all_button.setEnabled(has_errors)
self.retry_button.setEnabled(has_errors)
self.export_button.setEnabled(has_errors)
def _populate_list(self):
"""Populates the list widget with details of the failed files."""
for error_info in self.error_files:
filename = error_info.get('forced_filename_override',
error_info.get('file_info', {}).get('name', 'Unknown Filename'))
post_title = error_info.get('post_title', 'Unknown Post')
post_id = error_info.get('original_post_id_for_log', 'N/A')
item_text = f"File: {filename}\nFrom Post: '{post_title}' (ID: {post_id})"
list_item = QListWidgetItem(item_text)
list_item.setData(Qt.UserRole, error_info)
list_item.setFlags(list_item.flags() | Qt.ItemIsUserCheckable)
list_item.setCheckState(Qt.Unchecked)
self.files_list_widget.addItem(list_item)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("error_files_dialog_title", "Files Skipped Due to Errors"))
if not self.error_files:
self.info_label.setText(self._tr("error_files_no_errors_label", "No files were recorded as skipped..."))
else:
self.info_label.setText(self._tr("error_files_found_label", "The following {count} file(s)...").format(count=len(self.error_files)))
self.select_all_button.setText(self._tr("error_files_select_all_button", "Select All"))
self.retry_button.setText(self._tr("error_files_retry_selected_button", "Retry Selected"))
self.export_button.setText(self._tr("error_files_export_urls_button", "Export URLs to .txt"))
self.ok_button.setText(self._tr("ok_button", "OK"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
def _select_all_items(self):
"""Checks all items in the list."""
if hasattr(self, 'files_list_widget'):
for i in range(self.files_list_widget.count()):
self.files_list_widget.item(i).setCheckState(Qt.Checked)
def _handle_retry_selected(self):
"""Gathers selected files and emits the retry signal."""
if not hasattr(self, 'files_list_widget'):
return
selected_files_for_retry = [
self.files_list_widget.item(i).data(Qt.UserRole)
for i in range(self.files_list_widget.count())
if self.files_list_widget.item(i).checkState() == Qt.Checked
]
if selected_files_for_retry:
self.retry_selected_signal.emit(selected_files_for_retry)
self.accept()
else:
QMessageBox.information(
self,
self._tr("fav_artists_no_selection_title", "No Selection"),
self._tr("error_files_no_selection_retry_message", "Please select at least one file to retry.")
)
def _handle_export_errors_to_txt(self):
"""Exports the URLs of failed files to a text file."""
if not self.error_files:
QMessageBox.information(
self,
self._tr("error_files_no_errors_export_title", "No Errors"),
self._tr("error_files_no_errors_export_message", "There are no error file URLs to export.")
)
return
options_dialog = ExportOptionsDialog(parent_app=self.parent_app, parent=self)
if not options_dialog.exec_() == QDialog.Accepted:
return
export_option = options_dialog.get_selected_option()
lines_to_export = []
for error_item in self.error_files:
file_info = error_item.get('file_info', {})
url = file_info.get('url')
if url:
if export_option == ExportOptionsDialog.EXPORT_MODE_WITH_DETAILS:
original_filename = file_info.get('name', 'Unknown Filename')
post_title = error_item.get('post_title', 'Unknown Post')
post_id = error_item.get('original_post_id_for_log', 'N/A')
details_string = f" [Post: '{post_title}' (ID: {post_id}), File: '{original_filename}']"
lines_to_export.append(f"{url}{details_string}")
else:
lines_to_export.append(url)
if not lines_to_export:
QMessageBox.information(
self,
self._tr("error_files_no_urls_found_export_title", "No URLs Found"),
self._tr("error_files_no_urls_found_export_message", "Could not extract any URLs...")
)
return
default_filename = "error_file_links.txt"
filepath, _ = QFileDialog.getSaveFileName(
self,
self._tr("error_files_save_dialog_title", "Save Error File URLs"),
default_filename,
"Text Files (*.txt);;All Files (*)"
)
if filepath:
try:
with open(filepath, 'w', encoding='utf-8') as f:
for line in lines_to_export:
f.write(f"{line}\n")
QMessageBox.information(
self,
self._tr("error_files_export_success_title", "Export Successful"),
self._tr("error_files_export_success_message", "Successfully exported...").format(
count=len(lines_to_export), filepath=filepath
)
)
except Exception as e:
QMessageBox.critical(
self,
self._tr("error_files_export_error_title", "Export Error"),
self._tr("error_files_export_error_message", "Could not export...").format(error=str(e))
)

View File

@@ -0,0 +1,118 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QRadioButton, QButtonGroup
)
# --- Local Application Imports ---
# This assumes the new project structure is in place.
from ...i18n.translator import get_translation
# get_app_icon_object is defined in the main window module in this refactoring plan.
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class ExportOptionsDialog(QDialog):
"""
Dialog to choose the export format for error file links.
It allows the user to select between exporting only the URLs or
exporting URLs with additional details.
"""
# Constants to define the export modes
EXPORT_MODE_LINK_ONLY = 1
EXPORT_MODE_WITH_DETAILS = 2
def __init__(self, parent_app, parent=None):
"""
Initializes the dialog.
Args:
parent_app (DownloaderApp): A reference to the main application window for theming and translations.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app
self.setModal(True)
# Default option
self.selected_option = self.EXPORT_MODE_LINK_ONLY
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# Set window size dynamically
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 768
scale_factor = screen_height / 768.0
base_min_w = 350
scaled_min_w = int(base_min_w * scale_factor)
self.setMinimumWidth(scaled_min_w)
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
layout = QVBoxLayout(self)
self.description_label = QLabel()
layout.addWidget(self.description_label)
self.radio_group = QButtonGroup(self)
self.radio_link_only = QRadioButton()
self.radio_link_only.setChecked(True)
self.radio_group.addButton(self.radio_link_only, self.EXPORT_MODE_LINK_ONLY)
layout.addWidget(self.radio_link_only)
self.radio_with_details = QRadioButton()
self.radio_group.addButton(self.radio_with_details, self.EXPORT_MODE_WITH_DETAILS)
layout.addWidget(self.radio_with_details)
# --- Action Buttons ---
button_layout = QHBoxLayout()
self.export_button = QPushButton()
self.export_button.clicked.connect(self._handle_export)
self.export_button.setDefault(True)
self.cancel_button = QPushButton()
self.cancel_button.clicked.connect(self.reject)
button_layout.addStretch(1)
button_layout.addWidget(self.export_button)
button_layout.addWidget(self.cancel_button)
layout.addLayout(button_layout)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("export_options_dialog_title", "Export Options"))
self.description_label.setText(self._tr("export_options_description_label", "Choose the format for exporting error file links:"))
self.radio_link_only.setText(self._tr("export_options_radio_link_only", "Link per line (URL only)"))
self.radio_link_only.setToolTip(self._tr("export_options_radio_link_only_tooltip", "Exports only the direct download URL..."))
self.radio_with_details.setText(self._tr("export_options_radio_with_details", "Export with details (URL [Post, File info])"))
self.radio_with_details.setToolTip(self._tr("export_options_radio_with_details_tooltip", "Exports the URL followed by details..."))
self.export_button.setText(self._tr("export_options_export_button", "Export"))
self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
if hasattr(self.parent_app, 'get_dark_theme'):
self.setStyleSheet(self.parent_app.get_dark_theme())
def _handle_export(self):
"""Sets the selected export option and accepts the dialog."""
self.selected_option = self.radio_group.checkedId()
self.accept()
def get_selected_option(self):
"""Returns the export mode chosen by the user."""
return self.selected_option

View File

@@ -0,0 +1,303 @@
# --- Standard Library Imports ---
import html
import re
# --- Third-Party Library Imports ---
import requests
from PyQt5.QtCore import QCoreApplication, Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
# Corrected Import: Get the icon from the new assets utility module
from ..assets import get_app_icon_object
from ...utils.network_utils import prepare_cookies_for_request
from .CookieHelpDialog import CookieHelpDialog
from ...utils.resolution import get_dark_theme
class FavoriteArtistsDialog (QDialog ):
"""Dialog to display and select favorite artists."""
def __init__ (self ,parent_app ,cookies_config ):
super ().__init__ (parent_app )
self .parent_app =parent_app
self .cookies_config =cookies_config
self .all_fetched_artists =[]
app_icon =get_app_icon_object ()
if not app_icon .isNull ():
self .setWindowIcon (app_icon )
self .selected_artist_urls =[]
self .setModal (True )
self .setMinimumSize (500 ,500 )
self ._init_ui ()
self ._fetch_favorite_artists ()
def _get_domain_for_service (self ,service_name ):
service_lower =service_name .lower ()
coomer_primary_services ={'onlyfans','fansly','manyvids','candfans'}
if service_lower in coomer_primary_services :
return "coomer.su"
else :
return "kemono.su"
def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language."""
if callable (get_translation )and self .parent_app :
return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text
def _retranslate_ui (self ):
self .setWindowTitle (self ._tr ("fav_artists_dialog_title","Favorite Artists"))
self .status_label .setText (self ._tr ("fav_artists_loading_status","Loading favorite artists..."))
self .search_input .setPlaceholderText (self ._tr ("fav_artists_search_placeholder","Search artists..."))
self .select_all_button .setText (self ._tr ("fav_artists_select_all_button","Select All"))
self .deselect_all_button .setText (self ._tr ("fav_artists_deselect_all_button","Deselect All"))
self .download_button .setText (self ._tr ("fav_artists_download_selected_button","Download Selected"))
self .cancel_button .setText (self ._tr ("fav_artists_cancel_button","Cancel"))
def _init_ui (self ):
main_layout =QVBoxLayout (self )
self .status_label =QLabel ()
self .status_label .setAlignment (Qt .AlignCenter )
main_layout .addWidget (self .status_label )
self .search_input =QLineEdit ()
self .search_input .textChanged .connect (self ._filter_artist_list_display )
main_layout .addWidget (self .search_input )
self .artist_list_widget =QListWidget ()
self .artist_list_widget .setStyleSheet ("""
QListWidget::item {
border-bottom: 1px solid #4A4A4A; /* Slightly softer line */
padding-top: 4px;
padding-bottom: 4px;
}""")
main_layout .addWidget (self .artist_list_widget )
self .artist_list_widget .setAlternatingRowColors (True )
self .search_input .setVisible (False )
self .artist_list_widget .setVisible (False )
combined_buttons_layout =QHBoxLayout ()
self .select_all_button =QPushButton ()
self .select_all_button .clicked .connect (self ._select_all_items )
combined_buttons_layout .addWidget (self .select_all_button )
self .deselect_all_button =QPushButton ()
self .deselect_all_button .clicked .connect (self ._deselect_all_items )
combined_buttons_layout .addWidget (self .deselect_all_button )
self .download_button =QPushButton ()
self .download_button .clicked .connect (self ._accept_selection_action )
self .download_button .setEnabled (False )
self .download_button .setDefault (True )
combined_buttons_layout .addWidget (self .download_button )
self .cancel_button =QPushButton ()
self .cancel_button .clicked .connect (self .reject )
combined_buttons_layout .addWidget (self .cancel_button )
combined_buttons_layout .addStretch (1 )
main_layout .addLayout (combined_buttons_layout )
self ._retranslate_ui ()
if hasattr (self .parent_app ,'get_dark_theme')and self .parent_app .current_theme =="dark":
self .setStyleSheet (self .parent_app .get_dark_theme ())
def _logger (self ,message ):
"""Helper to log messages, either to parent app or console."""
if hasattr (self .parent_app ,'log_signal')and self .parent_app .log_signal :
self .parent_app .log_signal .emit (f"[FavArtistsDialog] {message }")
else :
print (f"[FavArtistsDialog] {message }")
def _show_content_elements (self ,show ):
"""Helper to show/hide content-related widgets."""
self .search_input .setVisible (show )
self .artist_list_widget .setVisible (show )
def _fetch_favorite_artists (self ):
if self.cookies_config['use_cookie']:
# Check if we can load cookies for at least one of the services.
kemono_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="kemono.su")
coomer_cookies = prepare_cookies_for_request(True, self.cookies_config['cookie_text'], self.cookies_config['selected_cookie_file'], self.cookies_config['app_base_dir'], self._logger, target_domain="coomer.su")
if not kemono_cookies and not coomer_cookies:
# If cookies are enabled but none could be loaded, show help and stop.
self.status_label.setText(self._tr("fav_artists_cookies_required_status", "Error: Cookies enabled but could not be loaded for any source."))
self._logger("Error: Cookies enabled but no valid cookies were loaded. Showing help dialog.")
cookie_help_dialog = CookieHelpDialog(self.parent_app, self)
cookie_help_dialog.exec_()
self.download_button.setEnabled(False)
return # Stop further execution
kemono_fav_url ="https://kemono.su/api/v1/account/favorites?type=artist"
coomer_fav_url ="https://coomer.su/api/v1/account/favorites?type=artist"
self .all_fetched_artists =[]
fetched_any_successfully =False
errors_occurred =[]
any_cookies_loaded_successfully_for_any_source =False
api_sources =[
{"name":"Kemono.su","url":kemono_fav_url ,"domain":"kemono.su"},
{"name":"Coomer.su","url":coomer_fav_url ,"domain":"coomer.su"}
]
for source in api_sources :
self ._logger (f"Attempting to fetch favorite artists from: {source ['name']} ({source ['url']})")
self .status_label .setText (self ._tr ("fav_artists_loading_from_source_status","⏳ Loading favorites from {source_name}...").format (source_name =source ['name']))
QCoreApplication .processEvents ()
cookies_dict_for_source =None
if self .cookies_config ['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request (
True ,
self .cookies_config ['cookie_text'],
self .cookies_config ['selected_cookie_file'],
self .cookies_config ['app_base_dir'],
self ._logger ,
target_domain =source ['domain']
)
if cookies_dict_for_source :
any_cookies_loaded_successfully_for_any_source =True
else :
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
try :
headers ={'User-Agent':'Mozilla/5.0'}
response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
response .raise_for_status ()
artists_data_from_api =response .json ()
if not isinstance (artists_data_from_api ,list ):
error_msg =f"Error ({source ['name']}): API did not return a list of artists (got {type (artists_data_from_api )})."
self ._logger (error_msg )
errors_occurred .append (error_msg )
continue
processed_artists_from_source =0
for artist_entry in artists_data_from_api :
artist_id =artist_entry .get ("id")
artist_name =html .unescape (artist_entry .get ("name","Unknown Artist").strip ())
artist_service_platform =artist_entry .get ("service")
if artist_id and artist_name and artist_service_platform :
artist_page_domain =self ._get_domain_for_service (artist_service_platform )
full_url =f"https://{artist_page_domain }/{artist_service_platform }/user/{artist_id }"
self .all_fetched_artists .append ({
'name':artist_name ,
'url':full_url ,
'service':artist_service_platform ,
'id':artist_id ,
'_source_api':source ['name']
})
processed_artists_from_source +=1
else :
self ._logger (f"Warning ({source ['name']}): Skipping favorite artist entry due to missing data: {artist_entry }")
if processed_artists_from_source >0 :
fetched_any_successfully =True
self ._logger (f"Fetched {processed_artists_from_source } artists from {source ['name']}.")
except requests .exceptions .RequestException as e :
error_msg =f"Error fetching favorites from {source ['name']}: {e }"
self ._logger (error_msg )
errors_occurred .append (error_msg )
except Exception as e :
error_msg =f"An unexpected error occurred with {source ['name']}: {e }"
self ._logger (error_msg )
errors_occurred .append (error_msg )
if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
self .status_label .setText (self ._tr ("fav_artists_cookies_required_status","Error: Cookies enabled but could not be loaded for any source."))
self ._logger ("Error: Cookies enabled but no cookies loaded for any source. Showing help dialog.")
cookie_help_dialog =CookieHelpDialog (self )
cookie_help_dialog .exec_ ()
self .download_button .setEnabled (False )
if not fetched_any_successfully :
errors_occurred .append ("Cookies enabled but could not be loaded for any API source.")
unique_artists_map ={}
for artist in self .all_fetched_artists :
key =(artist ['service'].lower (),str (artist ['id']).lower ())
if key not in unique_artists_map :
unique_artists_map [key ]=artist
self .all_fetched_artists =list (unique_artists_map .values ())
self .all_fetched_artists .sort (key =lambda x :x ['name'].lower ())
self ._populate_artist_list_widget ()
if fetched_any_successfully and self .all_fetched_artists :
self .status_label .setText (self ._tr ("fav_artists_found_status","Found {count} total favorite artist(s).").format (count =len (self .all_fetched_artists )))
self ._show_content_elements (True )
self .download_button .setEnabled (True )
elif not fetched_any_successfully and not errors_occurred :
self .status_label .setText (self ._tr ("fav_artists_none_found_status","No favorite artists found on Kemono.su or Coomer.su."))
self ._show_content_elements (False )
self .download_button .setEnabled (False )
else :
final_error_message =self ._tr ("fav_artists_failed_status","Failed to fetch favorites.")
if errors_occurred :
final_error_message +=" Errors: "+"; ".join (errors_occurred )
self .status_label .setText (final_error_message )
self ._show_content_elements (False )
self .download_button .setEnabled (False )
if fetched_any_successfully and not self .all_fetched_artists :
self .status_label .setText (self ._tr ("fav_artists_no_favorites_after_processing","No favorite artists found after processing."))
def _populate_artist_list_widget (self ,artists_to_display =None ):
self .artist_list_widget .clear ()
source_list =artists_to_display if artists_to_display is not None else self .all_fetched_artists
for artist_data in source_list :
item =QListWidgetItem (f"{artist_data ['name']} ({artist_data .get ('service','N/A').capitalize ()})")
item .setFlags (item .flags ()|Qt .ItemIsUserCheckable )
item .setCheckState (Qt .Unchecked )
item .setData (Qt .UserRole ,artist_data )
self .artist_list_widget .addItem (item )
def _filter_artist_list_display (self ):
search_text =self .search_input .text ().lower ().strip ()
if not search_text :
self ._populate_artist_list_widget ()
return
filtered_artists =[
artist for artist in self .all_fetched_artists
if search_text in artist ['name'].lower ()or search_text in artist ['url'].lower ()
]
self ._populate_artist_list_widget (filtered_artists )
def _select_all_items (self ):
for i in range (self .artist_list_widget .count ()):
self .artist_list_widget .item (i ).setCheckState (Qt .Checked )
def _deselect_all_items (self ):
for i in range (self .artist_list_widget .count ()):
self .artist_list_widget .item (i ).setCheckState (Qt .Unchecked )
def _accept_selection_action (self ):
self .selected_artists_data =[]
for i in range (self .artist_list_widget .count ()):
item =self .artist_list_widget .item (i )
if item .checkState ()==Qt .Checked :
self .selected_artists_data .append (item .data (Qt .UserRole ))
if not self .selected_artists_data :
QMessageBox .information (self ,"No Selection","Please select at least one artist to download.")
return
self .accept ()
def get_selected_artists (self ):
return self .selected_artists_data

View File

@@ -0,0 +1,623 @@
import html
import os
import sys
import threading
import time
import traceback
import json
import re
from collections import defaultdict
import requests
from PyQt5.QtCore import QCoreApplication, Qt, pyqtSignal, QThread
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QProgressBar,
QWidget, QCheckBox
)
from ...i18n.translator import get_translation
from ..assets import get_app_icon_object
from ...utils.network_utils import prepare_cookies_for_request
from .CookieHelpDialog import CookieHelpDialog
from ...core.api_client import download_from_api
from ...utils.resolution import get_dark_theme
class FavoritePostsFetcherThread (QThread ):
"""Worker thread to fetch favorite posts and creator names."""
status_update =pyqtSignal (str )
progress_bar_update =pyqtSignal (int ,int )
finished =pyqtSignal (list ,str )
def __init__ (self ,cookies_config ,parent_logger_func ,target_domain_preference =None ):
super ().__init__ ()
self .cookies_config =cookies_config
self .parent_logger_func =parent_logger_func
self .target_domain_preference =target_domain_preference
self .cancellation_event =threading .Event ()
self .error_key_map ={
"Kemono.su":"kemono_su",
"Coomer.su":"coomer_su"
}
def _logger (self ,message ):
self .parent_logger_func (f"[FavPostsFetcherThread] {message }")
def run (self ):
kemono_fav_posts_url ="https://kemono.su/api/v1/account/favorites?type=post"
coomer_fav_posts_url ="https://coomer.su/api/v1/account/favorites?type=post"
all_fetched_posts_temp =[]
error_messages_for_summary =[]
fetched_any_successfully =False
any_cookies_loaded_successfully_for_any_source =False
self .status_update .emit ("key_fetching_fav_post_list_init")
self .progress_bar_update .emit (0 ,0 )
api_sources =[
{"name":"Kemono.su","url":kemono_fav_posts_url ,"domain":"kemono.su"},
{"name":"Coomer.su","url":coomer_fav_posts_url ,"domain":"coomer.su"}
]
api_sources_to_try =[]
if self .target_domain_preference :
self ._logger (f"Targeting specific domain for favorites: {self .target_domain_preference }")
for source_def in api_sources :
if source_def ["domain"]==self .target_domain_preference :
api_sources_to_try .append (source_def )
break
if not api_sources_to_try :
self ._logger (f"Warning: Preferred domain '{self .target_domain_preference }' not a recognized API source. Fetching from all.")
api_sources_to_try =api_sources
else :
self ._logger ("No specific domain preference, or both domains have cookies. Will attempt to fetch from all sources.")
api_sources_to_try =api_sources
for source in api_sources_to_try :
if self .cancellation_event .is_set ():
self .finished .emit ([],"KEY_FETCH_CANCELLED_DURING")
return
cookies_dict_for_source =None
if self .cookies_config ['use_cookie']:
cookies_dict_for_source =prepare_cookies_for_request (
True ,
self .cookies_config ['cookie_text'],
self .cookies_config ['selected_cookie_file'],
self .cookies_config ['app_base_dir'],
self ._logger ,
target_domain =source ['domain']
)
if cookies_dict_for_source :
any_cookies_loaded_successfully_for_any_source =True
else :
self ._logger (f"Warning ({source ['name']}): Cookies enabled but could not be loaded for this domain. Fetch might fail if cookies are required.")
self ._logger (f"Attempting to fetch favorite posts from: {source ['name']} ({source ['url']})")
source_key_part =self .error_key_map .get (source ['name'],source ['name'].lower ().replace ('.','_'))
self .status_update .emit (f"key_fetching_from_source_{source_key_part }")
QCoreApplication .processEvents ()
try :
headers ={'User-Agent':'Mozilla/5.0'}
response =requests .get (source ['url'],headers =headers ,cookies =cookies_dict_for_source ,timeout =20 )
response .raise_for_status ()
posts_data_from_api =response .json ()
if not isinstance (posts_data_from_api ,list ):
err_detail =f"Error ({source ['name']}): API did not return a list of posts (got {type (posts_data_from_api )})."
self ._logger (err_detail )
error_messages_for_summary .append (err_detail )
continue
processed_posts_from_source =0
for post_entry in posts_data_from_api :
post_id =post_entry .get ("id")
post_title =html .unescape (post_entry .get ("title","Untitled Post").strip ())
service =post_entry .get ("service")
creator_id =post_entry .get ("user")
added_date_str =post_entry .get ("added",post_entry .get ("published",""))
if post_id and post_title and service and creator_id :
all_fetched_posts_temp .append ({
'post_id':post_id ,'title':post_title ,'service':service ,
'creator_id':creator_id ,'added_date':added_date_str ,
'_source_api':source ['name']
})
processed_posts_from_source +=1
else :
self ._logger (f"Warning ({source ['name']}): Skipping favorite post entry due to missing data: {post_entry }")
if processed_posts_from_source >0 :
fetched_any_successfully =True
self ._logger (f"Fetched {processed_posts_from_source } posts from {source ['name']}.")
except requests .exceptions .RequestException as e :
err_detail =f"Error fetching favorite posts from {source ['name']}: {e }"
self ._logger (err_detail )
error_messages_for_summary .append (err_detail )
if e .response is not None and e .response .status_code ==401 :
self .finished .emit ([],"KEY_AUTH_FAILED")
self ._logger (f"Authorization failed for {source ['name']}, emitting KEY_AUTH_FAILED.")
return
except Exception as e :
err_detail =f"An unexpected error occurred with {source ['name']}: {e }"
self ._logger (err_detail )
error_messages_for_summary .append (err_detail )
if self .cancellation_event .is_set ():
self .finished .emit ([],"KEY_FETCH_CANCELLED_AFTER")
return
if self .cookies_config ['use_cookie']and not any_cookies_loaded_successfully_for_any_source :
if self .target_domain_preference and not any_cookies_loaded_successfully_for_any_source :
domain_key_part =self .error_key_map .get (self .target_domain_preference ,self .target_domain_preference .lower ().replace ('.','_'))
self .finished .emit ([],f"KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_{domain_key_part }")
return
self .finished .emit ([],"KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC")
return
unique_posts_map ={}
for post in all_fetched_posts_temp :
key =(post ['service'].lower (),str (post ['creator_id']).lower (),str (post ['post_id']).lower ())
if key not in unique_posts_map :
unique_posts_map [key ]=post
all_fetched_posts_temp =list (unique_posts_map .values ())
all_fetched_posts_temp .sort (key =lambda x :(x .get ('_source_api','').lower (),x .get ('service','').lower (),str (x .get ('creator_id','')).lower (),(x .get ('added_date')or '')),reverse =False )
if error_messages_for_summary :
error_summary_str ="; ".join (error_messages_for_summary )
if not fetched_any_successfully :
self .finished .emit ([],f"KEY_FETCH_FAILED_GENERIC_{error_summary_str [:50 ]}")
else :
self .finished .emit (all_fetched_posts_temp ,f"KEY_FETCH_PARTIAL_SUCCESS_{error_summary_str [:50 ]}")
elif not all_fetched_posts_temp and not fetched_any_successfully and not self .target_domain_preference :
self .finished .emit ([],"KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS")
else :
self .finished .emit (all_fetched_posts_temp ,"KEY_FETCH_SUCCESS")
class PostListItemWidget (QWidget ):
"""Custom widget for displaying a single post in the FavoritePostsDialog list."""
def __init__ (self ,post_data_dict ,parent_dialog_ref ,parent =None ):
super ().__init__ (parent )
self .post_data =post_data_dict
self .parent_dialog =parent_dialog_ref
self .layout =QHBoxLayout (self )
self .layout .setContentsMargins (5 ,3 ,5 ,3 )
self .layout .setSpacing (10 )
self .checkbox =QCheckBox ()
self .layout .addWidget (self .checkbox )
self .info_label =QLabel ()
self .info_label .setWordWrap (True )
self .info_label .setTextFormat (Qt .RichText )
self .layout .addWidget (self .info_label ,1 )
self ._setup_display_text ()
def _setup_display_text (self ):
suffix_plain =self .post_data .get ('suffix_for_display',"")
title_plain =self .post_data .get ('title','Untitled Post')
escaped_suffix =html .escape (suffix_plain )
escaped_title =html .escape (title_plain )
p_style_paragraph ="font-size:10.5pt; margin:0; padding:0;"
title_span_style ="font-weight:bold; color:#E0E0E0;"
suffix_span_style ="color:#999999; font-weight:normal; font-size:9.5pt;"
if escaped_suffix :
display_html_content =f"<p style='{p_style_paragraph }'><span style='{title_span_style }'>{escaped_title }</span><span style='{suffix_span_style }'>{escaped_suffix }</span></p>"
else :
display_html_content =f"<p style='{p_style_paragraph }'><span style='{title_span_style }'>{escaped_title }</span></p>"
self .info_label .setText (display_html_content )
def isChecked (self ):return self .checkbox .isChecked ()
def setCheckState (self ,state ):self .checkbox .setCheckState (state )
def get_post_data (self ):return self .post_data
class FavoritePostsDialog (QDialog ):
"""Dialog to display and select favorite posts."""
def __init__ (self ,parent_app ,cookies_config ,known_names_list_ref ,target_domain_preference =None ):
super ().__init__ (parent_app )
self .parent_app =parent_app
self .cookies_config =cookies_config
self .all_fetched_posts =[]
self .selected_posts_data =[]
self .known_names_list_ref =known_names_list_ref
self .target_domain_preference_for_this_fetch =target_domain_preference
self .creator_name_cache ={}
self .displayable_grouped_posts ={}
self .fetcher_thread =None
app_icon =get_app_icon_object ()
if not app_icon .isNull ():
self .setWindowIcon (app_icon )
self .setModal (True )
self .setMinimumSize (600 ,600 )
if hasattr (self .parent_app ,'get_dark_theme'):
self .setStyleSheet (self .parent_app .get_dark_theme ())
self ._init_ui ()
self ._load_creator_names_from_file ()
self ._retranslate_ui ()
self ._start_fetching_favorite_posts ()
def _update_status_label_from_key (self ,status_key ):
"""Translates a status key and updates the status label."""
translated_status =self ._tr (status_key .lower (),status_key )
self .status_label .setText (translated_status )
def _init_ui (self ):
main_layout =QVBoxLayout (self )
self .status_label =QLabel ()
self .status_label .setAlignment (Qt .AlignCenter )
main_layout .addWidget (self .status_label )
self .progress_bar =QProgressBar ()
self .progress_bar .setTextVisible (False )
self .progress_bar .setVisible (False )
main_layout .addWidget (self .progress_bar )
self .search_input =QLineEdit ()
self .search_input .textChanged .connect (self ._filter_post_list_display )
main_layout .addWidget (self .search_input )
self .post_list_widget =QListWidget ()
self .post_list_widget .setStyleSheet ("""
QListWidget::item {
border-bottom: 1px solid #4A4A4A;
padding-top: 4px;
padding-bottom: 4px;
}""")
self .post_list_widget .setAlternatingRowColors (True )
main_layout .addWidget (self .post_list_widget )
combined_buttons_layout =QHBoxLayout ()
self .select_all_button =QPushButton ()
self .select_all_button .clicked .connect (self ._select_all_items )
combined_buttons_layout .addWidget (self .select_all_button )
self .deselect_all_button =QPushButton ()
self .deselect_all_button .clicked .connect (self ._deselect_all_items )
combined_buttons_layout .addWidget (self .deselect_all_button )
self .download_button =QPushButton ()
self .download_button .clicked .connect (self ._accept_selection_action )
self .download_button .setEnabled (False )
self .download_button .setDefault (True )
combined_buttons_layout .addWidget (self .download_button )
self .cancel_button =QPushButton ()
self .cancel_button .clicked .connect (self .reject )
combined_buttons_layout .addWidget (self .cancel_button )
combined_buttons_layout .addStretch (1 )
main_layout .addLayout (combined_buttons_layout )
def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language."""
if callable (get_translation )and self .parent_app :
return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text
def _retranslate_ui (self ):
self .setWindowTitle (self ._tr ("fav_posts_dialog_title","Favorite Posts"))
self .status_label .setText (self ._tr ("fav_posts_loading_status","Loading favorite posts..."))
self .search_input .setPlaceholderText (self ._tr ("fav_posts_search_placeholder","Search posts (title, creator name, ID, service)..."))
self .select_all_button .setText (self ._tr ("fav_posts_select_all_button","Select All"))
self .deselect_all_button .setText (self ._tr ("fav_posts_deselect_all_button","Deselect All"))
self .download_button .setText (self ._tr ("fav_posts_download_selected_button","Download Selected"))
self .cancel_button .setText (self ._tr ("fav_posts_cancel_button","Cancel"))
def _logger (self ,message ):
if hasattr (self .parent_app ,'log_signal')and self .parent_app .log_signal :
self .parent_app .log_signal .emit (f"[FavPostsDialog] {message }")
else :
print (f"[FavPostsDialog] {message }")
def _load_creator_names_from_file (self ):
"""Loads creator id-name-service mappings from creators.txt."""
self ._logger ("Attempting to load creators.json for Favorite Posts Dialog.")
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
base_path_for_creators =sys ._MEIPASS
self ._logger (f" Running bundled. Using _MEIPASS: {base_path_for_creators }")
else :
base_path_for_creators =self .parent_app .app_base_dir
self ._logger (f" Not bundled or _MEIPASS unavailable. Using app_base_dir: {base_path_for_creators }")
creators_file_path = os.path.join(base_path_for_creators, "data", "creators.json")
self ._logger (f"Full path to creators.json: {creators_file_path }")
if not os .path .exists (creators_file_path ):
self ._logger (f"Warning: 'creators.json' not found at {creators_file_path }. Creator names will not be displayed.")
return
try :
with open (creators_file_path ,'r',encoding ='utf-8')as f :
loaded_data =json .load (f )
if isinstance (loaded_data ,list )and len (loaded_data )>0 and isinstance (loaded_data [0 ],list ):
creators_list =loaded_data [0 ]
elif isinstance (loaded_data ,list )and all (isinstance (item ,dict )for item in loaded_data ):
creators_list =loaded_data
else :
self ._logger (f"Warning: 'creators.json' has an unexpected format. Expected a list of lists or a flat list of creator objects.")
return
for creator_data in creators_list :
creator_id =creator_data .get ("id")
name =creator_data .get ("name")
service =creator_data .get ("service")
if creator_id and name and service :
self .creator_name_cache [(service .lower (),str (creator_id ))]=name
self ._logger (f"Successfully loaded {len (self .creator_name_cache )} creator names from 'creators.json'.")
except Exception as e :
self ._logger (f"Error loading 'creators.json': {e }")
def _start_fetching_favorite_posts (self ):
self .download_button .setEnabled (False )
self .status_label .setText ("Initializing favorite posts fetch...")
self .fetcher_thread =FavoritePostsFetcherThread (
self .cookies_config ,
self .parent_app .log_signal .emit ,
target_domain_preference =self .target_domain_preference_for_this_fetch
)
self .fetcher_thread .status_update .connect (self ._update_status_label_from_key )
self .fetcher_thread .finished .connect (self ._on_fetch_completed )
self .fetcher_thread .progress_bar_update .connect (self ._set_progress_bar_value )
self .progress_bar .setVisible (True )
self .fetcher_thread .start ()
def _set_progress_bar_value (self ,value ,maximum ):
if maximum ==0 :
self .progress_bar .setRange (0 ,0 )
self .progress_bar .setValue (0 )
else :
self .progress_bar .setRange (0 ,maximum )
self .progress_bar .setValue (value )
def _on_fetch_completed (self ,fetched_posts_list ,status_key ):
self .progress_bar .setVisible (False )
proceed_to_display_posts =False
show_error_message_box =False
message_box_title_key ="fav_posts_fetch_error_title"
message_box_text_key ="fav_posts_fetch_error_message"
message_box_params ={'domain':self .target_domain_preference_for_this_fetch or "platform",'error_message_part':""}
status_label_text_key =None
if status_key =="KEY_FETCH_SUCCESS":
proceed_to_display_posts =True
elif status_key and status_key .startswith ("KEY_FETCH_PARTIAL_SUCCESS_")and fetched_posts_list :
displayable_detail =status_key .replace ("KEY_FETCH_PARTIAL_SUCCESS_","").replace ("_"," ")
self ._logger (f"Partial success with posts: {status_key } -> {displayable_detail }")
proceed_to_display_posts =True
elif status_key :
specific_domain_msg_part =f" for {self .target_domain_preference_for_this_fetch }"if self .target_domain_preference_for_this_fetch else ""
if status_key .startswith ("KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_FOR_DOMAIN_")or status_key =="KEY_COOKIES_REQUIRED_BUT_NOT_FOUND_GENERIC":
status_label_text_key ="fav_posts_cookies_required_error"
self ._logger (f"Cookie error: {status_key }. Showing help dialog.")
cookie_help_dialog =CookieHelpDialog (self )
cookie_help_dialog .exec_ ()
elif status_key =="KEY_AUTH_FAILED":
status_label_text_key ="fav_posts_auth_failed_title"
self ._logger (f"Auth error: {status_key }. Showing help dialog.")
QMessageBox .warning (self ,self ._tr ("fav_posts_auth_failed_title","Authorization Failed (Posts)"),
self ._tr ("fav_posts_auth_failed_message_generic","...").format (domain_specific_part =specific_domain_msg_part ))
cookie_help_dialog =CookieHelpDialog (self )
cookie_help_dialog .exec_ ()
elif status_key =="KEY_NO_FAVORITES_FOUND_ALL_PLATFORMS":
status_label_text_key ="fav_posts_no_posts_found_status"
self ._logger (status_key )
elif status_key .startswith ("KEY_FETCH_CANCELLED"):
status_label_text_key ="fav_posts_fetch_cancelled_status"
self ._logger (status_key )
else :
displayable_error_detail =status_key
if status_key .startswith ("KEY_FETCH_FAILED_GENERIC_"):
displayable_error_detail =status_key .replace ("KEY_FETCH_FAILED_GENERIC_","").replace ("_"," ")
elif status_key .startswith ("KEY_FETCH_PARTIAL_SUCCESS_"):
displayable_error_detail =status_key .replace ("KEY_FETCH_PARTIAL_SUCCESS_","Partial success but no posts: ").replace ("_"," ")
message_box_params ['error_message_part']=f":\n\n{displayable_error_detail }"if displayable_error_detail else ""
status_label_text_key ="fav_posts_fetch_error_message"
show_error_message_box =True
self ._logger (f"Fetch error: {status_key } -> {displayable_error_detail }")
if status_label_text_key :
self .status_label .setText (self ._tr (status_label_text_key ,status_label_text_key ).format (**message_box_params ))
if show_error_message_box :
QMessageBox .critical (self ,self ._tr (message_box_title_key ),self ._tr (message_box_text_key ).format (**message_box_params ))
self .download_button .setEnabled (False )
return
if not proceed_to_display_posts :
if not status_label_text_key :
self .status_label .setText (self ._tr ("fav_posts_cookies_required_error","Error: Cookies are required for favorite posts but could not be loaded."))
self .download_button .setEnabled (False )
return
if not self .creator_name_cache :
self ._logger ("Warning: Creator name cache is empty. Names will not be resolved from creators.json. Displaying IDs instead.")
else :
self ._logger (f"Creator name cache has {len (self .creator_name_cache )} entries. Attempting to resolve names...")
sample_keys =list (self .creator_name_cache .keys ())[:3 ]
if sample_keys :
self ._logger (f"Sample keys from creator_name_cache: {sample_keys }")
processed_one_missing_log =False
for post_entry in fetched_posts_list :
service_from_post =post_entry .get ('service','')
creator_id_from_post =post_entry .get ('creator_id','')
lookup_key_service =service_from_post .lower ()
lookup_key_id =str (creator_id_from_post )
lookup_key_tuple =(lookup_key_service ,lookup_key_id )
resolved_name =self .creator_name_cache .get (lookup_key_tuple )
if resolved_name :
post_entry ['creator_name_resolved']=resolved_name
else :
post_entry ['creator_name_resolved']=str (creator_id_from_post )
if not processed_one_missing_log and self .creator_name_cache :
self ._logger (f"Debug: Name not found for key {lookup_key_tuple }. Using ID '{creator_id_from_post }'.")
processed_one_missing_log =True
self .all_fetched_posts =fetched_posts_list
if not self .all_fetched_posts :
self .status_label .setText (self ._tr ("fav_posts_no_posts_found_status","No favorite posts found."))
self .download_button .setEnabled (False )
return
try :
self ._populate_post_list_widget ()
self .status_label .setText (self ._tr ("fav_posts_found_status","{count} favorite post(s) found.").format (count =len (self .all_fetched_posts )))
self .download_button .setEnabled (True )
except Exception as e :
self .status_label .setText (self ._tr ("fav_posts_display_error_status","Error displaying posts: {error}").format (error =str (e )))
self ._logger (f"Error during _populate_post_list_widget: {e }\n{traceback .format_exc (limit =3 )}")
QMessageBox .critical (self ,self ._tr ("fav_posts_ui_error_title","UI Error"),self ._tr ("fav_posts_ui_error_message","Could not display favorite posts: {error}").format (error =str (e )))
self .download_button .setEnabled (False )
def _find_best_known_name_match_in_title (self ,title_raw ):
if not title_raw or not self .known_names_list_ref :
return None
title_lower =title_raw .lower ()
best_match_known_name_primary =None
longest_match_len =0
for known_entry in self .known_names_list_ref :
aliases_to_check =set ()
for alias_val in known_entry .get ("aliases",[]):
aliases_to_check .add (alias_val )
if not known_entry .get ("is_group",False ):
aliases_to_check .add (known_entry ["name"])
sorted_aliases_for_entry =sorted (list (aliases_to_check ),key =len ,reverse =True )
for alias in sorted_aliases_for_entry :
alias_lower =alias .lower ()
if not alias_lower :
continue
if re .search (r'\b'+re .escape (alias_lower )+r'\b',title_lower ):
if len (alias_lower )>longest_match_len :
longest_match_len =len (alias_lower )
best_match_known_name_primary =known_entry ["name"]
break
return best_match_known_name_primary
def _populate_post_list_widget (self ,posts_to_display =None ):
self .post_list_widget .clear ()
source_list_for_grouping =posts_to_display if posts_to_display is not None else self .all_fetched_posts
grouped_posts ={}
for post in source_list_for_grouping :
service =post .get ('service','unknown_service')
creator_id =post .get ('creator_id','unknown_id')
group_key =(service ,creator_id )
if group_key not in grouped_posts :
grouped_posts [group_key ]=[]
grouped_posts [group_key ].append (post )
sorted_group_keys =sorted (grouped_posts .keys (),key =lambda x :(x [0 ].lower (),x [1 ].lower ()))
self .displayable_grouped_posts ={
key :sorted (grouped_posts [key ],key =lambda p :(p .get ('added_date')or ''),reverse =True )
for key in sorted_group_keys
}
for service ,creator_id_val in sorted_group_keys :
creator_name_display =self .creator_name_cache .get (
(service .lower (),str (creator_id_val )),
str (creator_id_val )
)
artist_header_display_text =f"{creator_name_display } ({service .capitalize ()} / {creator_id_val })"
artist_header_item =QListWidgetItem (f"🎨 {artist_header_display_text }")
artist_header_item .setFlags (Qt .NoItemFlags )
font =artist_header_item .font ()
font .setBold (True )
font .setPointSize (font .pointSize ()+1 )
artist_header_item .setFont (font )
artist_header_item .setForeground (Qt .cyan )
self .post_list_widget .addItem (artist_header_item )
for post_data in self .displayable_grouped_posts [(service ,creator_id_val )]:
post_title_raw =post_data .get ('title','Untitled Post')
found_known_name_primary =self ._find_best_known_name_match_in_title (post_title_raw )
plain_text_title_for_list_item =post_title_raw
if found_known_name_primary :
suffix_text =f" [Known - {found_known_name_primary }]"
post_data ['suffix_for_display']=suffix_text
plain_text_title_for_list_item =post_title_raw +suffix_text
else :
post_data .pop ('suffix_for_display',None )
list_item =QListWidgetItem (self .post_list_widget )
list_item .setText (plain_text_title_for_list_item )
list_item .setFlags (list_item .flags ()|Qt .ItemIsUserCheckable )
list_item .setCheckState (Qt .Unchecked )
list_item .setData (Qt .UserRole ,post_data )
self .post_list_widget .addItem (list_item )
def _filter_post_list_display (self ):
search_text =self .search_input .text ().lower ().strip ()
if not search_text :
self ._populate_post_list_widget (self .all_fetched_posts )
return
filtered_posts_to_group =[]
for post in self .all_fetched_posts :
matches_post_title =search_text in post .get ('title','').lower ()
matches_creator_name =search_text in post .get ('creator_name_resolved','').lower ()
matches_creator_id =search_text in post .get ('creator_id','').lower ()
matches_service =search_text in post ['service'].lower ()
if matches_post_title or matches_creator_name or matches_creator_id or matches_service :
filtered_posts_to_group .append (post )
self ._populate_post_list_widget (filtered_posts_to_group )
def _select_all_items (self ):
for i in range (self .post_list_widget .count ()):
item =self .post_list_widget .item (i )
if item and item .flags ()&Qt .ItemIsUserCheckable :
item .setCheckState (Qt .Checked )
def _deselect_all_items (self ):
for i in range (self .post_list_widget .count ()):
item =self .post_list_widget .item (i )
if item and item .flags ()&Qt .ItemIsUserCheckable :
item .setCheckState (Qt .Unchecked )
def _accept_selection_action (self ):
self .selected_posts_data =[]
for i in range (self .post_list_widget .count ()):
item =self .post_list_widget .item (i )
if item and item .checkState ()==Qt .Checked :
post_data_for_download =item .data (Qt .UserRole )
self .selected_posts_data .append (post_data_for_download )
if not self .selected_posts_data :
QMessageBox .information (self ,self ._tr ("fav_posts_no_selection_title","No Selection"),self ._tr ("fav_posts_no_selection_message","Please select at least one post to download."))
return
self .accept ()
def get_selected_posts (self ):
return self .selected_posts_data

View File

@@ -0,0 +1,296 @@
# --- Standard Library Imports ---
import os
import json
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt, QStandardPaths
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QGroupBox, QComboBox, QMessageBox, QGridLayout, QCheckBox
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ...utils.resolution import get_dark_theme
from ..main_window import get_app_icon_object
from ...config.constants import (
THEME_KEY, LANGUAGE_KEY, DOWNLOAD_LOCATION_KEY,
RESOLUTION_KEY, UI_SCALE_KEY, SAVE_CREATOR_JSON_KEY
)
class FutureSettingsDialog(QDialog):
"""
A dialog for managing application-wide settings like theme, language,
and display options, with an organized layout.
"""
def __init__(self, parent_app_ref, parent=None):
super().__init__(parent)
self.parent_app = parent_app_ref
self.setModal(True)
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
screen_height = QApplication.primaryScreen().availableGeometry().height() if QApplication.primaryScreen() else 800
scale_factor = screen_height / 800.0
base_min_w, base_min_h = 420, 360 # Adjusted height for new layout
scaled_min_w = int(base_min_w * scale_factor)
scaled_min_h = int(base_min_h * scale_factor)
self.setMinimumSize(scaled_min_w, scaled_min_h)
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
# --- Group 1: Interface Settings ---
self.interface_group_box = QGroupBox()
interface_layout = QGridLayout(self.interface_group_box)
# Theme
self.theme_label = QLabel()
self.theme_toggle_button = QPushButton()
self.theme_toggle_button.clicked.connect(self._toggle_theme)
interface_layout.addWidget(self.theme_label, 0, 0)
interface_layout.addWidget(self.theme_toggle_button, 0, 1)
# UI Scale
self.ui_scale_label = QLabel()
self.ui_scale_combo_box = QComboBox()
self.ui_scale_combo_box.currentIndexChanged.connect(self._display_setting_changed)
interface_layout.addWidget(self.ui_scale_label, 1, 0)
interface_layout.addWidget(self.ui_scale_combo_box, 1, 1)
# Language
self.language_label = QLabel()
self.language_combo_box = QComboBox()
self.language_combo_box.currentIndexChanged.connect(self._language_selection_changed)
interface_layout.addWidget(self.language_label, 2, 0)
interface_layout.addWidget(self.language_combo_box, 2, 1)
main_layout.addWidget(self.interface_group_box)
# --- Group 2: Download & Window Settings ---
self.download_window_group_box = QGroupBox()
download_window_layout = QGridLayout(self.download_window_group_box)
# Window Size (Resolution)
self.window_size_label = QLabel()
self.resolution_combo_box = QComboBox()
self.resolution_combo_box.currentIndexChanged.connect(self._display_setting_changed)
download_window_layout.addWidget(self.window_size_label, 0, 0)
download_window_layout.addWidget(self.resolution_combo_box, 0, 1)
# Default Path
self.default_path_label = QLabel()
self.save_path_button = QPushButton()
self.save_path_button.clicked.connect(self._save_download_path)
download_window_layout.addWidget(self.default_path_label, 1, 0)
download_window_layout.addWidget(self.save_path_button, 1, 1)
# Save Creator.json Checkbox
self.save_creator_json_checkbox = QCheckBox()
self.save_creator_json_checkbox.stateChanged.connect(self._creator_json_setting_changed)
download_window_layout.addWidget(self.save_creator_json_checkbox, 2, 0, 1, 2)
main_layout.addWidget(self.download_window_group_box)
main_layout.addStretch(1)
# --- OK Button ---
self.ok_button = QPushButton()
self.ok_button.clicked.connect(self.accept)
main_layout.addWidget(self.ok_button, 0, Qt.AlignRight | Qt.AlignBottom)
def _load_checkbox_states(self):
"""Loads the initial state for all checkboxes from settings."""
self.save_creator_json_checkbox.blockSignals(True)
# Default to True so the feature is on by default for users
should_save = self.parent_app.settings.value(SAVE_CREATOR_JSON_KEY, True, type=bool)
self.save_creator_json_checkbox.setChecked(should_save)
self.save_creator_json_checkbox.blockSignals(False)
def _creator_json_setting_changed(self, state):
"""Saves the state of the 'Save Creator.json' checkbox."""
is_checked = state == Qt.Checked
self.parent_app.settings.setValue(SAVE_CREATOR_JSON_KEY, is_checked)
self.parent_app.settings.sync()
def _tr(self, key, default_text=""):
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
self.setWindowTitle(self._tr("settings_dialog_title", "Settings"))
# Group Box Titles
self.interface_group_box.setTitle(self._tr("interface_group_title", "Interface Settings"))
self.download_window_group_box.setTitle(self._tr("download_window_group_title", "Download & Window Settings"))
# Interface Group Labels
self.theme_label.setText(self._tr("theme_label", "Theme:"))
self.ui_scale_label.setText(self._tr("ui_scale_label", "UI Scale:"))
self.language_label.setText(self._tr("language_label", "Language:"))
# Download & Window Group Labels
self.window_size_label.setText(self._tr("window_size_label", "Window Size:"))
self.default_path_label.setText(self._tr("default_path_label", "Default Path:"))
self.save_creator_json_checkbox.setText(self._tr("save_creator_json_label", "Save Creator.json file"))
# Buttons and Controls
self._update_theme_toggle_button_text()
self.save_path_button.setText(self._tr("settings_save_path_button", "Save Current Download Path"))
self.save_path_button.setToolTip(self._tr("settings_save_path_tooltip", "Save the current 'Download Location' for future sessions."))
self.ok_button.setText(self._tr("ok_button", "OK"))
# Populate dropdowns
self._populate_display_combo_boxes()
self._populate_language_combo_box()
self._load_checkbox_states()
def _apply_theme(self):
if self.parent_app and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("")
def _update_theme_toggle_button_text(self):
if self.parent_app.current_theme == "dark":
self.theme_toggle_button.setText(self._tr("theme_toggle_light", "Switch to Light Mode"))
else:
self.theme_toggle_button.setText(self._tr("theme_toggle_dark", "Switch to Dark Mode"))
def _toggle_theme(self):
new_theme = "light" if self.parent_app.current_theme == "dark" else "dark"
self.parent_app.settings.setValue(THEME_KEY, new_theme)
self.parent_app.settings.sync()
self.parent_app.current_theme = new_theme
self._apply_theme()
if hasattr(self.parent_app, '_apply_theme_and_restart_prompt'):
self.parent_app._apply_theme_and_restart_prompt()
def _populate_display_combo_boxes(self):
self.resolution_combo_box.blockSignals(True)
self.resolution_combo_box.clear()
resolutions = [
("Auto", self._tr("auto_resolution", "Auto (System Default)")),
("1280x720", "1280 x 720"),
("1600x900", "1600 x 900"),
("1920x1080", "1920 x 1080 (Full HD)"),
("2560x1440", "2560 x 1440 (2K)"),
("3840x2160", "3840 x 2160 (4K)")
]
current_res = self.parent_app.settings.value(RESOLUTION_KEY, "Auto")
for res_key, res_name in resolutions:
self.resolution_combo_box.addItem(res_name, res_key)
if current_res == res_key:
self.resolution_combo_box.setCurrentIndex(self.resolution_combo_box.count() - 1)
self.resolution_combo_box.blockSignals(False)
self.ui_scale_combo_box.blockSignals(True)
self.ui_scale_combo_box.clear()
scales = [
(0.5, "50%"),
(0.7, "70%"),
(0.9, "90%"),
(1.0, "100% (Default)"),
(1.25, "125%"),
(1.50, "150%"),
(1.75, "175%"),
(2.0, "200%")
]
current_scale = float(self.parent_app.settings.value(UI_SCALE_KEY, 1.0))
for scale_val, scale_name in scales:
self.ui_scale_combo_box.addItem(scale_name, scale_val)
if abs(current_scale - scale_val) < 0.01:
self.ui_scale_combo_box.setCurrentIndex(self.ui_scale_combo_box.count() - 1)
self.ui_scale_combo_box.blockSignals(False)
def _display_setting_changed(self):
selected_res = self.resolution_combo_box.currentData()
selected_scale = self.ui_scale_combo_box.currentData()
self.parent_app.settings.setValue(RESOLUTION_KEY, selected_res)
self.parent_app.settings.setValue(UI_SCALE_KEY, selected_scale)
self.parent_app.settings.sync()
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(self._tr("display_change_title", "Display Settings Changed"))
msg_box.setText(self._tr("language_change_message", "A restart is required for these changes to take effect."))
msg_box.setInformativeText(self._tr("language_change_informative", "Would you like to restart now?"))
restart_button = msg_box.addButton(self._tr("restart_now_button", "Restart Now"), QMessageBox.ApplyRole)
ok_button = msg_box.addButton(self._tr("ok_button", "OK"), QMessageBox.AcceptRole)
msg_box.setDefaultButton(ok_button)
msg_box.exec_()
if msg_box.clickedButton() == restart_button:
self.parent_app._request_restart_application()
def _populate_language_combo_box(self):
self.language_combo_box.blockSignals(True)
self.language_combo_box.clear()
languages = [
("en", "English"), ("ja", "日本語 (Japanese)"), ("fr", "Français (French)"),
("de", "Deutsch (German)"), ("es", "Español (Spanish)"), ("pt", "Português (Portuguese)"),
("ru", "Русский (Russian)"), ("zh_CN", "简体中文 (Simplified Chinese)"),
("zh_TW", "繁體中文 (Traditional Chinese)"), ("ko", "한국어 (Korean)")
]
current_lang = self.parent_app.current_selected_language
for lang_code, lang_name in languages:
self.language_combo_box.addItem(lang_name, lang_code)
if current_lang == lang_code:
self.language_combo_box.setCurrentIndex(self.language_combo_box.count() - 1)
self.language_combo_box.blockSignals(False)
def _language_selection_changed(self, index):
selected_lang_code = self.language_combo_box.itemData(index)
if selected_lang_code and selected_lang_code != self.parent_app.current_selected_language:
self.parent_app.settings.setValue(LANGUAGE_KEY, selected_lang_code)
self.parent_app.settings.sync()
self.parent_app.current_selected_language = selected_lang_code
self._retranslate_ui()
if hasattr(self.parent_app, '_retranslate_main_ui'):
self.parent_app._retranslate_main_ui()
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Information)
msg_box.setWindowTitle(self._tr("language_change_title", "Language Changed"))
msg_box.setText(self._tr("language_change_message", "A restart is required..."))
msg_box.setInformativeText(self._tr("language_change_informative", "Would you like to restart now?"))
restart_button = msg_box.addButton(self._tr("restart_now_button", "Restart Now"), QMessageBox.ApplyRole)
ok_button = msg_box.addButton(self._tr("ok_button", "OK"), QMessageBox.AcceptRole)
msg_box.setDefaultButton(ok_button)
msg_box.exec_()
if msg_box.clickedButton() == restart_button:
self.parent_app._request_restart_application()
def _save_download_path(self):
if hasattr(self.parent_app, 'dir_input') and self.parent_app.dir_input:
current_path = self.parent_app.dir_input.text().strip()
if current_path and os.path.isdir(current_path):
self.parent_app.settings.setValue(DOWNLOAD_LOCATION_KEY, current_path)
self.parent_app.settings.sync()
QMessageBox.information(self,
self._tr("settings_save_path_success_title", "Path Saved"),
self._tr("settings_save_path_success_message", "Download location '{path}' saved.").format(path=current_path))
elif not current_path:
QMessageBox.warning(self,
self._tr("settings_save_path_empty_title", "Empty Path"),
self._tr("settings_save_path_empty_message", "Download location cannot be empty."))
else:
QMessageBox.warning(self,
self._tr("settings_save_path_invalid_title", "Invalid Path"),
self._tr("settings_save_path_invalid_message", "The path '{path}' is not a valid directory.").format(path=current_path))
else:
QMessageBox.critical(self, "Error", "Could not access download path input from main application.")

View File

@@ -0,0 +1,203 @@
import os
import sys
from PyQt5.QtCore import QUrl, QSize, Qt
from PyQt5.QtGui import QIcon, QDesktopServices
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget
)
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class TourStepWidget(QWidget):
"""
A custom widget representing a single step or page in the feature guide.
It neatly formats a title and its corresponding content.
"""
def __init__(self, title_text, content_text, parent=None, scale=1.0):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
title_font_size = int(14 * scale)
content_font_size = int(11 * scale)
title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet(f"font-size: {title_font_size}pt; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
layout.addWidget(title_label)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.NoFrame)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setStyleSheet("background-color: transparent;")
content_label = QLabel(content_text)
content_label.setWordWrap(True)
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
content_label.setTextFormat(Qt.RichText)
content_label.setOpenExternalLinks(True)
content_label.setStyleSheet(f"font-size: {content_font_size}pt; color: #C8C8C8; line-height: 1.8;")
scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1)
class HelpGuideDialog (QDialog ):
"""A multi-page dialog for displaying the feature guide."""
def __init__ (self ,steps_data ,parent_app ,parent =None ):
super ().__init__ (parent )
self .current_step =0
self .steps_data =steps_data
self .parent_app =parent_app
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
self.setModal(True)
self.resize(int(650 * scale), int(600 * scale))
dialog_font_size = int(11 * scale)
current_theme_style = ""
if hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
current_theme_style = get_dark_theme(scale)
else:
current_theme_style = f"""
QDialog {{ background-color: #F0F0F0; border: 1px solid #B0B0B0; }}
QLabel {{ color: #1E1E1E; }}
QPushButton {{
background-color: #E1E1E1;
color: #1E1E1E;
border: 1px solid #ADADAD;
padding: {int(8*scale)}px {int(15*scale)}px;
border-radius: 4px;
min-height: {int(25*scale)}px;
font-size: {dialog_font_size}pt;
}}
QPushButton:hover {{ background-color: #CACACA; }}
QPushButton:pressed {{ background-color: #B0B0B0; }}
"""
self.setStyleSheet(current_theme_style)
self ._init_ui ()
if self .parent_app :
self .move (self .parent_app .geometry ().center ()-self .rect ().center ())
def _tr (self ,key ,default_text =""):
"""Helper to get translation based on current app language."""
if callable (get_translation )and self .parent_app :
return get_translation (self .parent_app .current_selected_language ,key ,default_text )
return default_text
def _init_ui (self ):
main_layout =QVBoxLayout (self )
main_layout .setContentsMargins (0 ,0 ,0 ,0 )
main_layout .setSpacing (0 )
self .stacked_widget =QStackedWidget ()
main_layout .addWidget (self .stacked_widget ,1 )
self .tour_steps_widgets =[]
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
for title, content in self.steps_data:
step_widget = TourStepWidget(title, content, scale=scale)
self.tour_steps_widgets.append(step_widget)
self.stacked_widget.addWidget(step_widget)
self .setWindowTitle (self ._tr ("help_guide_dialog_title","Kemono Downloader - Feature Guide"))
buttons_layout =QHBoxLayout ()
buttons_layout .setContentsMargins (15 ,10 ,15 ,15 )
buttons_layout .setSpacing (10 )
self .back_button =QPushButton (self ._tr ("tour_dialog_back_button","Back"))
self .back_button .clicked .connect (self ._previous_step )
self .back_button .setEnabled (False )
if getattr (sys ,'frozen',False )and hasattr (sys ,'_MEIPASS'):
assets_base_dir =sys ._MEIPASS
else :
assets_base_dir =os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
github_icon_path =os .path .join (assets_base_dir ,"assets","github.png")
instagram_icon_path =os .path .join (assets_base_dir ,"assets","instagram.png")
discord_icon_path =os .path .join (assets_base_dir ,"assets","discord.png")
self .github_button =QPushButton (QIcon (github_icon_path ),"")
self .instagram_button =QPushButton (QIcon (instagram_icon_path ),"")
self .Discord_button =QPushButton (QIcon (discord_icon_path ),"")
scale = self.parent_app.scale_factor if hasattr(self.parent_app, 'scale_factor') else 1.0
icon_dim = int(24 * scale)
icon_size = QSize(icon_dim, icon_dim)
self .github_button .setIconSize (icon_size )
self .instagram_button .setIconSize (icon_size )
self .Discord_button .setIconSize (icon_size )
self .next_button =QPushButton (self ._tr ("tour_dialog_next_button","Next"))
self .next_button .clicked .connect (self ._next_step_action )
self .next_button .setDefault (True )
self .github_button .clicked .connect (self ._open_github_link )
self .instagram_button .clicked .connect (self ._open_instagram_link )
self .Discord_button .clicked .connect (self ._open_Discord_link )
self .github_button .setToolTip (self ._tr ("help_guide_github_tooltip","Visit project's GitHub page (Opens in browser)"))
self .instagram_button .setToolTip (self ._tr ("help_guide_instagram_tooltip","Visit our Instagram page (Opens in browser)"))
self .Discord_button .setToolTip (self ._tr ("help_guide_discord_tooltip","Visit our Discord community (Opens in browser)"))
social_layout =QHBoxLayout ()
social_layout .setSpacing (10 )
social_layout .addWidget (self .github_button )
social_layout .addWidget (self .instagram_button )
social_layout .addWidget (self .Discord_button )
while buttons_layout .count ():
item =buttons_layout .takeAt (0 )
if item .widget ():
item .widget ().setParent (None )
elif item .layout ():
pass
buttons_layout .addLayout (social_layout )
buttons_layout .addStretch (1 )
buttons_layout .addWidget (self .back_button )
buttons_layout .addWidget (self .next_button )
main_layout .addLayout (buttons_layout )
self ._update_button_states ()
def _next_step_action (self ):
if self .current_step <len (self .tour_steps_widgets )-1 :
self .current_step +=1
self .stacked_widget .setCurrentIndex (self .current_step )
else :
self .accept ()
self ._update_button_states ()
def _previous_step (self ):
if self .current_step >0 :
self .current_step -=1
self .stacked_widget .setCurrentIndex (self .current_step )
self ._update_button_states ()
def _update_button_states (self ):
if self .current_step ==len (self .tour_steps_widgets )-1 :
self .next_button .setText (self ._tr ("tour_dialog_finish_button","Finish"))
else :
self .next_button .setText (self ._tr ("tour_dialog_next_button","Next"))
self .back_button .setEnabled (self .current_step >0 )
def _open_github_link (self ):
QDesktopServices .openUrl (QUrl ("https://github.com/Yuvi9587"))
def _open_instagram_link (self ):
QDesktopServices .openUrl (QUrl ("https://www.instagram.com/uvi.arts/"))
def _open_Discord_link (self ):
QDesktopServices .openUrl (QUrl ("https://discord.gg/BqP64XTdJN"))

View File

@@ -0,0 +1,107 @@
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QGroupBox, QRadioButton,
QPushButton, QHBoxLayout, QButtonGroup, QLabel, QLineEdit
)
from PyQt5.QtGui import QIntValidator
from ...i18n.translator import get_translation
from ...config.constants import DUPLICATE_HANDLING_HASH, DUPLICATE_HANDLING_KEEP_ALL
class KeepDuplicatesDialog(QDialog):
"""A dialog to choose the duplicate handling method, with a limit option."""
def __init__(self, current_mode, current_limit, parent=None):
super().__init__(parent)
self.parent_app = parent
self.selected_mode = current_mode
self.limit = current_limit
self._init_ui()
self._retranslate_ui()
if self.parent_app and hasattr(self.parent_app, '_apply_theme_to_widget'):
self.parent_app._apply_theme_to_widget(self)
if current_mode == DUPLICATE_HANDLING_KEEP_ALL:
self.radio_keep_everything.setChecked(True)
self.limit_input.setText(str(current_limit) if current_limit > 0 else "")
else:
self.radio_skip_by_hash.setChecked(True)
self.limit_input.setEnabled(False)
def _init_ui(self):
"""Initializes the UI components."""
main_layout = QVBoxLayout(self)
info_label = QLabel()
info_label.setWordWrap(True)
main_layout.addWidget(info_label)
options_group = QGroupBox()
options_layout = QVBoxLayout(options_group)
self.button_group = QButtonGroup(self)
self.radio_skip_by_hash = QRadioButton()
self.button_group.addButton(self.radio_skip_by_hash)
options_layout.addWidget(self.radio_skip_by_hash)
keep_everything_layout = QHBoxLayout()
self.radio_keep_everything = QRadioButton()
self.button_group.addButton(self.radio_keep_everything)
keep_everything_layout.addWidget(self.radio_keep_everything)
keep_everything_layout.addStretch(1)
self.limit_label = QLabel()
self.limit_input = QLineEdit()
self.limit_input.setValidator(QIntValidator(0, 99))
self.limit_input.setFixedWidth(50)
keep_everything_layout.addWidget(self.limit_label)
keep_everything_layout.addWidget(self.limit_input)
options_layout.addLayout(keep_everything_layout)
main_layout.addWidget(options_group)
button_layout = QHBoxLayout()
self.ok_button = QPushButton()
self.cancel_button = QPushButton()
button_layout.addStretch(1)
button_layout.addWidget(self.ok_button)
button_layout.addWidget(self.cancel_button)
main_layout.addLayout(button_layout)
self.ok_button.clicked.connect(self.accept)
self.cancel_button.clicked.connect(self.reject)
self.radio_keep_everything.toggled.connect(self.limit_input.setEnabled)
def _tr(self, key, default_text=""):
if self.parent_app and callable(get_translation):
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for UI elements."""
self.setWindowTitle(self._tr("duplicates_dialog_title", "Duplicate Handling Options"))
self.findChild(QLabel).setText(self._tr("duplicates_dialog_info",
"Choose how to handle files that have identical content to already downloaded files."))
self.findChild(QGroupBox).setTitle(self._tr("duplicates_dialog_group_title", "Mode"))
self.radio_skip_by_hash.setText(self._tr("duplicates_dialog_skip_hash", "Skip by Hash (Recommended)"))
self.radio_keep_everything.setText(self._tr("duplicates_dialog_keep_all", "Keep Everything"))
self.limit_label.setText(self._tr("duplicates_limit_label", "Limit:"))
self.limit_input.setPlaceholderText(self._tr("duplicates_limit_placeholder", "0=all"))
self.limit_input.setToolTip(self._tr("duplicates_limit_tooltip",
"Set a limit for identical files to keep. 0 means no limit."))
self.ok_button.setText(self._tr("ok_button", "OK"))
self.cancel_button.setText(self._tr("cancel_button_text_simple", "Cancel"))
def accept(self):
"""Sets the selected mode and limit when OK is clicked."""
if self.radio_keep_everything.isChecked():
self.selected_mode = DUPLICATE_HANDLING_KEEP_ALL
try:
self.limit = int(self.limit_input.text()) if self.limit_input.text() else 0
except ValueError:
self.limit = 0
else:
self.selected_mode = DUPLICATE_HANDLING_HASH
self.limit = 0
super().accept()
def get_selected_options(self):
"""Returns the chosen mode and limit as a dictionary."""
return {"mode": self.selected_mode, "limit": self.limit}

View File

@@ -0,0 +1,158 @@
# --- PyQt5 Imports ---
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QPushButton, QVBoxLayout
)
# --- Local Application Imports ---
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
class KnownNamesFilterDialog(QDialog):
"""
A dialog to select names from the Known.txt list to add to the main
character filter input field. This provides a convenient way for users
to reuse their saved names and groups for filtering downloads.
"""
def __init__(self, known_names_list, parent_app_ref, parent=None):
"""
Initializes the dialog.
Args:
known_names_list (list): A list of known name objects (dicts) from Known.txt.
parent_app_ref (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.parent_app = parent_app_ref
self.setModal(True)
self.all_known_name_entries = sorted(known_names_list, key=lambda x: x['name'].lower())
self.selected_entries_to_return = []
# --- Basic Window Setup ---
app_icon = get_app_icon_object()
if app_icon and not app_icon.isNull():
self.setWindowIcon(app_icon)
# --- START OF FIX ---
# Get the user-defined scale factor from the parent application
# instead of calculating an independent one.
scale_factor = getattr(self.parent_app, 'scale_factor', 1.0)
# Define base size and apply the correct scale factor
base_width, base_height = 460, 450
self.setMinimumSize(int(base_width * scale_factor), int(base_height * scale_factor))
self.resize(int(base_width * scale_factor * 1.1), int(base_height * scale_factor * 1.1))
# --- END OF FIX ---
# --- Initialize UI and Apply Theming ---
self._init_ui()
self._retranslate_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
main_layout = QVBoxLayout(self)
self.search_input = QLineEdit()
self.search_input.textChanged.connect(self._filter_list_display)
main_layout.addWidget(self.search_input)
self.names_list_widget = QListWidget()
self._populate_list_widget()
main_layout.addWidget(self.names_list_widget)
# --- Control Buttons ---
buttons_layout = QHBoxLayout()
self.select_all_button = QPushButton()
self.select_all_button.clicked.connect(self._select_all_items)
buttons_layout.addWidget(self.select_all_button)
self.deselect_all_button = QPushButton()
self.deselect_all_button.clicked.connect(self._deselect_all_items)
buttons_layout.addWidget(self.deselect_all_button)
buttons_layout.addStretch(1)
self.add_button = QPushButton()
self.add_button.clicked.connect(self._accept_selection_action)
self.add_button.setDefault(True)
buttons_layout.addWidget(self.add_button)
self.cancel_button = QPushButton()
self.cancel_button.clicked.connect(self.reject)
buttons_layout.addWidget(self.cancel_button)
main_layout.addLayout(buttons_layout)
def _tr(self, key, default_text=""):
"""Helper to get translation based on the main application's current language."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _retranslate_ui(self):
"""Sets the text for all translatable UI elements."""
self.setWindowTitle(self._tr("known_names_filter_dialog_title", "Add Known Names to Filter"))
self.search_input.setPlaceholderText(self._tr("known_names_filter_search_placeholder", "Search names..."))
self.select_all_button.setText(self._tr("known_names_filter_select_all_button", "Select All"))
self.deselect_all_button.setText(self._tr("known_names_filter_deselect_all_button", "Deselect All"))
self.add_button.setText(self._tr("known_names_filter_add_selected_button", "Add Selected"))
self.cancel_button.setText(self._tr("fav_posts_cancel_button", "Cancel"))
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")
def _populate_list_widget(self):
"""Populates the list widget with the known names."""
self.names_list_widget.clear()
for entry_obj in self.all_known_name_entries:
item = QListWidgetItem(entry_obj['name'])
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Unchecked)
item.setData(Qt.UserRole, entry_obj)
self.names_list_widget.addItem(item)
def _filter_list_display(self):
"""Filters the displayed list based on the search input text."""
search_text_lower = self.search_input.text().lower()
for i in range(self.names_list_widget.count()):
item = self.names_list_widget.item(i)
entry_obj = item.data(Qt.UserRole)
matches_search = not search_text_lower or search_text_lower in entry_obj['name'].lower()
item.setHidden(not matches_search)
def _select_all_items(self):
"""Checks all visible items in the list widget."""
for i in range(self.names_list_widget.count()):
item = self.names_list_widget.item(i)
if not item.isHidden():
item.setCheckState(Qt.Checked)
def _deselect_all_items(self):
"""Unchecks all items in the list widget."""
for i in range(self.names_list_widget.count()):
self.names_list_widget.item(i).setCheckState(Qt.Unchecked)
def _accept_selection_action(self):
"""Gathers the selected entries and accepts the dialog."""
self.selected_entries_to_return = []
for i in range(self.names_list_widget.count()):
item = self.names_list_widget.item(i)
if item.checkState() == Qt.Checked:
self.selected_entries_to_return.append(item.data(Qt.UserRole))
self.accept()
def get_selected_entries(self):
"""Returns the list of known name entries selected by the user."""
return self.selected_entries_to_return

View File

@@ -0,0 +1,96 @@
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QRadioButton, QDialogButtonBox, QButtonGroup, QLabel, QComboBox, QHBoxLayout, QCheckBox
)
from PyQt5.QtCore import Qt
from ...utils.resolution import get_dark_theme
class MoreOptionsDialog(QDialog):
"""
A dialog for selecting a scope, export format, and single PDF option.
"""
SCOPE_CONTENT = "content"
SCOPE_COMMENTS = "comments"
def __init__(self, parent=None, current_scope=None, current_format=None, single_pdf_checked=False):
super().__init__(parent)
self.parent_app = parent
self.setWindowTitle("More Options")
self.setMinimumWidth(350)
# ... (Layout and other widgets remain the same) ...
layout = QVBoxLayout(self)
self.description_label = QLabel("Please choose the scope for the action:")
layout.addWidget(self.description_label)
self.radio_button_group = QButtonGroup(self)
self.radio_content = QRadioButton("Description/Content")
self.radio_comments = QRadioButton("Comments (Not Working)")
self.radio_button_group.addButton(self.radio_content)
self.radio_button_group.addButton(self.radio_comments)
layout.addWidget(self.radio_content)
layout.addWidget(self.radio_comments)
if current_scope == self.SCOPE_COMMENTS:
self.radio_comments.setChecked(True)
else:
self.radio_content.setChecked(True)
export_layout = QHBoxLayout()
export_label = QLabel("Export as:")
self.format_combo = QComboBox()
self.format_combo.addItems(["PDF", "DOCX", "TXT"])
if current_format and current_format.upper() in ["PDF", "DOCX", "TXT"]:
self.format_combo.setCurrentText(current_format.upper())
else:
self.format_combo.setCurrentText("PDF")
export_layout.addWidget(export_label)
export_layout.addWidget(self.format_combo)
export_layout.addStretch()
layout.addLayout(export_layout)
# --- UPDATED: Single PDF Checkbox ---
self.single_pdf_checkbox = QCheckBox("Single PDF")
self.single_pdf_checkbox.setToolTip("If checked, all text from matching posts will be compiled into one single PDF file.")
self.single_pdf_checkbox.setChecked(single_pdf_checked)
layout.addWidget(self.single_pdf_checkbox)
self.format_combo.currentTextChanged.connect(self.update_single_pdf_checkbox_state)
self.update_single_pdf_checkbox_state(self.format_combo.currentText())
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
layout.addWidget(self.button_box)
self.setLayout(layout)
self._apply_theme()
def update_single_pdf_checkbox_state(self, text):
"""Enable the Single PDF checkbox only if the format is PDF."""
is_pdf = (text.upper() == "PDF")
self.single_pdf_checkbox.setEnabled(is_pdf)
if not is_pdf:
self.single_pdf_checkbox.setChecked(False)
def get_selected_scope(self):
if self.radio_comments.isChecked():
return self.SCOPE_COMMENTS
return self.SCOPE_CONTENT
def get_selected_format(self):
return self.format_combo.currentText().lower()
def get_single_pdf_state(self):
"""Returns the state of the Single PDF checkbox."""
return self.single_pdf_checkbox.isChecked() and self.single_pdf_checkbox.isEnabled()
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
# Get the scale factor from the parent app
scale = getattr(self.parent_app, 'scale_factor', 1)
# Call the imported function with the correct scale
self.setStyleSheet(get_dark_theme(scale))
else:
# Explicitly set a blank stylesheet for light mode
self.setStyleSheet("")

108
src/ui/dialogs/SinglePDF.py Normal file
View File

@@ -0,0 +1,108 @@
import os
import re
try:
from fpdf import FPDF
FPDF_AVAILABLE = True
except ImportError:
FPDF_AVAILABLE = False
def strip_html_tags(text):
if not text:
return ""
clean = re.compile('<.*?>')
return re.sub(clean, '', text)
class PDF(FPDF):
"""Custom PDF class to handle headers and footers."""
def header(self):
pass
def footer(self):
self.set_y(-15)
if self.font_family:
self.set_font(self.font_family, '', 8)
else:
self.set_font('Arial', '', 8)
self.cell(0, 10, 'Page ' + str(self.page_no()), 0, 0, 'C')
def create_single_pdf_from_content(posts_data, output_filename, font_path, logger=print):
"""
Creates a single, continuous PDF, correctly formatting both descriptions and comments.
"""
if not FPDF_AVAILABLE:
logger("❌ PDF Creation failed: 'fpdf2' library is not installed. Please run: pip install fpdf2")
return False
if not posts_data:
logger(" No text content was collected to create a PDF.")
return False
pdf = PDF()
default_font_family = 'DejaVu'
bold_font_path = ""
if font_path:
bold_font_path = font_path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
try:
if not os.path.exists(font_path): raise RuntimeError(f"Font file not found: {font_path}")
if not os.path.exists(bold_font_path): raise RuntimeError(f"Bold font file not found: {bold_font_path}")
pdf.add_font('DejaVu', '', font_path, uni=True)
pdf.add_font('DejaVu', 'B', bold_font_path, uni=True)
except Exception as font_error:
logger(f" ⚠️ Could not load DejaVu font: {font_error}. Falling back to Arial.")
default_font_family = 'Arial'
pdf.add_page()
logger(f" Starting continuous PDF creation with content from {len(posts_data)} posts...")
for i, post in enumerate(posts_data):
if i > 0:
if 'content' in post:
pdf.add_page()
elif 'comments' in post:
pdf.ln(10)
pdf.cell(0, 0, '', border='T')
pdf.ln(10)
pdf.set_font(default_font_family, 'B', 16)
pdf.multi_cell(w=0, h=10, text=post.get('title', 'Untitled Post'), align='L')
pdf.ln(5)
if 'comments' in post and post['comments']:
comments_list = post['comments']
for comment_index, comment in enumerate(comments_list):
user = comment.get('commenter_name', 'Unknown User')
timestamp = comment.get('published', 'No Date')
body = strip_html_tags(comment.get('content', ''))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, "Comment by: ")
if user is not None:
pdf.set_font(default_font_family, 'B', 10)
pdf.write(8, str(user))
pdf.set_font(default_font_family, '', 10)
pdf.write(8, f" on {timestamp}")
pdf.ln(10)
pdf.set_font(default_font_family, '', 11)
pdf.multi_cell(0, 7, body)
if comment_index < len(comments_list) - 1:
pdf.ln(3)
pdf.cell(w=0, h=0, border='T')
pdf.ln(3)
elif 'content' in post:
pdf.set_font(default_font_family, '', 12)
pdf.multi_cell(w=0, h=7, text=post.get('content', 'No Content'))
try:
pdf.output(output_filename)
logger(f"✅ Successfully created single PDF: '{os.path.basename(output_filename)}'")
return True
except Exception as e:
logger(f"❌ A critical error occurred while saving the final PDF: {e}")
return False

View File

@@ -0,0 +1,155 @@
# src/ui/dialogs/SupportDialog.py
# --- Standard Library Imports ---
import sys
import os
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QLabel, QFrame, QDialogButtonBox, QGridLayout
)
from PyQt5.QtCore import Qt, QSize
from PyQt5.QtGui import QFont, QPixmap
# --- Local Application Imports ---
from ...utils.resolution import get_dark_theme
# --- Helper function for robust asset loading ---
def get_asset_path(filename):
"""
Gets the absolute path to a file in the assets folder,
handling both development and frozen (PyInstaller) environments.
"""
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
# Running in a PyInstaller bundle
base_path = sys._MEIPASS
else:
# Running in a normal Python environment from src/ui/dialogs/
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base_path, 'assets', filename)
class SupportDialog(QDialog):
"""
A dialog to show support and donation options.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.parent_app = parent
self.setWindowTitle("❤️ Support the Developer")
self.setMinimumWidth(450)
self._init_ui()
self._apply_theme()
def _init_ui(self):
"""Initializes all UI components and layouts for the dialog."""
# Main layout
main_layout = QVBoxLayout(self)
main_layout.setSpacing(15)
# Title Label
title_label = QLabel("Thank You for Your Support!")
font = title_label.font()
font.setPointSize(14)
font.setBold(True)
title_label.setFont(font)
title_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title_label)
# Informational Text
info_label = QLabel(
"If you find this application useful, please consider supporting its development. "
"Your contribution helps cover costs and encourages future updates and features."
)
info_label.setWordWrap(True)
info_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(info_label)
# Separator
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
main_layout.addWidget(line)
# --- Donation Options Layout (using a grid for icons and text) ---
options_layout = QGridLayout()
options_layout.setSpacing(18)
options_layout.setColumnStretch(0, 1) # Add stretch to center the content horizontally
options_layout.setColumnStretch(3, 1)
link_font = self.font()
link_font.setPointSize(12)
link_font.setBold(True)
scale = getattr(self.parent_app, 'scale_factor', 1.0)
icon_size = int(32 * scale)
# --- Ko-fi ---
kofi_icon_label = QLabel()
kofi_pixmap = QPixmap(get_asset_path("kofi.png"))
if not kofi_pixmap.isNull():
kofi_icon_label.setPixmap(kofi_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
kofi_text_label = QLabel(
'<a href="https://ko-fi.com/yuvi427183" style="color: #13C2C2; text-decoration: none;">'
'☕ Buy me a Ko-fi'
'</a>'
)
kofi_text_label.setOpenExternalLinks(True)
kofi_text_label.setFont(link_font)
options_layout.addWidget(kofi_icon_label, 0, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(kofi_text_label, 0, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- GitHub Sponsors ---
github_icon_label = QLabel()
github_pixmap = QPixmap(get_asset_path("github_sponsors.png"))
if not github_pixmap.isNull():
github_icon_label.setPixmap(github_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
github_text_label = QLabel(
'<a href="https://github.com/sponsors/Yuvi9587" style="color: #EA4AAA; text-decoration: none;">'
'💜 Sponsor on GitHub'
'</a>'
)
github_text_label.setOpenExternalLinks(True)
github_text_label.setFont(link_font)
options_layout.addWidget(github_icon_label, 1, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(github_text_label, 1, 2, Qt.AlignLeft | Qt.AlignVCenter)
# --- Buy Me a Coffee (New) ---
bmac_icon_label = QLabel()
bmac_pixmap = QPixmap(get_asset_path("bmac.png"))
if not bmac_pixmap.isNull():
bmac_icon_label.setPixmap(bmac_pixmap.scaled(QSize(icon_size, icon_size), Qt.KeepAspectRatio, Qt.SmoothTransformation))
bmac_text_label = QLabel(
'<a href="https://buymeacoffee.com/yuvi9587" style="color: #FFDD00; text-decoration: none;">'
'🍺 Buy Me a Coffee'
'</a>'
)
bmac_text_label.setOpenExternalLinks(True)
bmac_text_label.setFont(link_font)
options_layout.addWidget(bmac_icon_label, 2, 1, Qt.AlignRight | Qt.AlignVCenter)
options_layout.addWidget(bmac_text_label, 2, 2, Qt.AlignLeft | Qt.AlignVCenter)
main_layout.addLayout(options_layout)
# Close Button
self.button_box = QDialogButtonBox(QDialogButtonBox.Close)
self.button_box.rejected.connect(self.reject)
main_layout.addWidget(self.button_box)
self.setLayout(main_layout)
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and hasattr(self.parent_app, 'current_theme') and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("")

View File

@@ -0,0 +1,208 @@
import os
import sys
from PyQt5.QtCore import pyqtSignal, Qt, QSettings, QCoreApplication
from PyQt5.QtWidgets import (
QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
QStackedWidget, QScrollArea, QFrame, QWidget, QCheckBox
)
from ...i18n.translator import get_translation
from ..main_window import get_app_icon_object
from ...utils.resolution import get_dark_theme
from ...config.constants import (
CONFIG_ORGANIZATION_NAME
)
class TourStepWidget(QWidget):
"""
A custom widget representing a single step or page in the feature tour.
It neatly formats a title and its corresponding content.
"""
def __init__(self, title_text, content_text, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
title_label = QLabel(title_text)
title_label.setAlignment(Qt.AlignCenter)
title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #E0E0E0; padding-bottom: 15px;")
layout.addWidget(title_label)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.NoFrame)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
scroll_area.setStyleSheet("background-color: transparent;")
content_label = QLabel(content_text)
content_label.setWordWrap(True)
content_label.setAlignment(Qt.AlignLeft | Qt.AlignTop)
content_label.setTextFormat(Qt.RichText)
content_label.setOpenExternalLinks(True)
content_label.setStyleSheet("font-size: 11pt; color: #C8C8C8; line-height: 1.8;")
scroll_area.setWidget(content_label)
layout.addWidget(scroll_area, 1)
class TourDialog(QDialog):
"""
A dialog that shows a multi-page tour to the user on first launch.
Includes a "Never show again" checkbox and uses QSettings to remember this preference.
"""
tour_finished_normally = pyqtSignal()
tour_skipped = pyqtSignal()
CONFIG_APP_NAME_TOUR = "ApplicationTour"
TOUR_SHOWN_KEY = "neverShowTourAgainV19"
def __init__(self, parent_app, parent=None):
"""
Initializes the dialog.
Args:
parent_app (DownloaderApp): A reference to the main application window.
parent (QWidget, optional): The parent widget. Defaults to None.
"""
super().__init__(parent)
self.settings = QSettings(CONFIG_ORGANIZATION_NAME, self.CONFIG_APP_NAME_TOUR)
self.current_step = 0
self.parent_app = parent_app
self.setWindowIcon(get_app_icon_object())
self.setModal(True)
self.setFixedSize(600, 620)
self._init_ui()
self._apply_theme()
self._center_on_screen()
def _tr(self, key, default_text=""):
"""Helper for translation."""
if callable(get_translation) and self.parent_app:
return get_translation(self.parent_app.current_selected_language, key, default_text)
return default_text
def _init_ui(self):
"""Initializes all UI components and layouts."""
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
self.stacked_widget = QStackedWidget()
main_layout.addWidget(self.stacked_widget, 1)
steps_content = [
("tour_dialog_step1_title", "tour_dialog_step1_content"),
("tour_dialog_step2_title", "tour_dialog_step2_content"),
("tour_dialog_step3_title", "tour_dialog_step3_content"),
("tour_dialog_step4_title", "tour_dialog_step4_content"),
("tour_dialog_step5_title", "tour_dialog_step5_content"),
("tour_dialog_step6_title", "tour_dialog_step6_content"),
("tour_dialog_step7_title", "tour_dialog_step7_content"),
("tour_dialog_step8_title", "tour_dialog_step8_content"),
]
self.tour_steps_widgets = []
for title_key, content_key in steps_content:
title = self._tr(title_key, title_key)
content = self._tr(content_key, "Content not found.")
step_widget = TourStepWidget(title, content)
self.tour_steps_widgets.append(step_widget)
self.stacked_widget.addWidget(step_widget)
self.setWindowTitle(self._tr("tour_dialog_title", "Welcome to Kemono Downloader!"))
bottom_controls_layout = QVBoxLayout()
bottom_controls_layout.setContentsMargins(15, 10, 15, 15)
bottom_controls_layout.setSpacing(12)
self.never_show_again_checkbox = QCheckBox(self._tr("tour_dialog_never_show_checkbox", "Never show this tour again"))
bottom_controls_layout.addWidget(self.never_show_again_checkbox, 0, Qt.AlignLeft)
buttons_layout = QHBoxLayout()
buttons_layout.setSpacing(10)
self.skip_button = QPushButton(self._tr("tour_dialog_skip_button", "Skip Tour"))
self.skip_button.clicked.connect(self._skip_tour_action)
self.back_button = QPushButton(self._tr("tour_dialog_back_button", "Back"))
self.back_button.clicked.connect(self._previous_step)
self.next_button = QPushButton(self._tr("tour_dialog_next_button", "Next"))
self.next_button.clicked.connect(self._next_step_action)
self.next_button.setDefault(True)
buttons_layout.addWidget(self.skip_button)
buttons_layout.addStretch(1)
buttons_layout.addWidget(self.back_button)
buttons_layout.addWidget(self.next_button)
bottom_controls_layout.addLayout(buttons_layout)
main_layout.addLayout(bottom_controls_layout)
self._update_button_states()
def _apply_theme(self):
"""Applies the current theme from the parent application."""
if self.parent_app and self.parent_app.current_theme == "dark":
scale = getattr(self.parent_app, 'scale_factor', 1)
self.setStyleSheet(get_dark_theme(scale))
else:
self.setStyleSheet("QDialog { background-color: #f0f0f0; }")
def _center_on_screen(self):
"""Centers the dialog on the screen."""
try:
screen_geo = QApplication.primaryScreen().availableGeometry()
self.move(screen_geo.center() - self.rect().center())
except Exception as e:
print(f"[TourDialog] Error centering dialog: {e}")
def _next_step_action(self):
"""Moves to the next step or finishes the tour."""
if self.current_step < len(self.tour_steps_widgets) - 1:
self.current_step += 1
self.stacked_widget.setCurrentIndex(self.current_step)
else:
self._finish_tour_action()
self._update_button_states()
def _previous_step(self):
"""Moves to the previous step."""
if self.current_step > 0:
self.current_step -= 1
self.stacked_widget.setCurrentIndex(self.current_step)
self._update_button_states()
def _update_button_states(self):
"""Updates the state and text of navigation buttons."""
is_last_step = self.current_step == len(self.tour_steps_widgets) - 1
self.next_button.setText(self._tr("tour_dialog_finish_button", "Finish") if is_last_step else self._tr("tour_dialog_next_button", "Next"))
self.back_button.setEnabled(self.current_step > 0)
def _skip_tour_action(self):
"""Handles the action when the tour is skipped."""
self._save_settings_if_checked()
self.tour_skipped.emit()
self.reject()
def _finish_tour_action(self):
"""Handles the action when the tour is finished normally."""
self._save_settings_if_checked()
self.tour_finished_normally.emit()
self.accept()
def _save_settings_if_checked(self):
"""Saves the 'never show again' preference to QSettings."""
self.settings.setValue(self.TOUR_SHOWN_KEY, self.never_show_again_checkbox.isChecked())
self.settings.sync()
@staticmethod
def should_show_tour():
"""Checks QSettings to see if the tour should be shown on startup."""
settings = QSettings(TourDialog.CONFIG_ORGANIZATION_NAME, TourDialog.CONFIG_APP_NAME_TOUR)
never_show = settings.value(TourDialog.TOUR_SHOWN_KEY, False, type=bool)
return not never_show
CONFIG_ORGANIZATION_NAME = CONFIG_ORGANIZATION_NAME
def closeEvent(self, event):
"""Ensures settings are saved if the dialog is closed via the 'X' button."""
self._skip_tour_action()
super().closeEvent(event)

View File

@@ -0,0 +1 @@
# ...existing code...

5417
src/ui/main_window.py Normal file

File diff suppressed because it is too large Load Diff

1
src/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
# ...existing code...

142
src/utils/file_utils.py Normal file
View File

@@ -0,0 +1,142 @@
# --- Standard Library Imports ---
import os
import re
# --- Module Constants ---
# This will be populated at runtime by the main application,
# but is defined here as it's conceptually related to file/folder naming.
KNOWN_NAMES = []
MAX_FILENAME_COMPONENT_LENGTH = 150
# Sets of file extensions for quick type checking
IMAGE_EXTENSIONS = {
'.jpg', '.jpeg', '.jpe', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp',
'.heic', '.heif', '.svg', '.ico', '.jfif', '.pjpeg', '.pjp', '.avif'
}
VIDEO_EXTENSIONS = {
'.mp4', '.mov', '.mkv', '.webm', '.avi', '.wmv', '.flv', '.mpeg',
'.mpg', '.m4v', '.3gp', '.ogv', '.ts', '.vob'
}
ARCHIVE_EXTENSIONS = {
'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2'
}
AUDIO_EXTENSIONS = {
'.mp3', '.wav', '.aac', '.flac', '.ogg', '.wma', '.m4a', '.opus',
'.aiff', '.ape', '.mid', '.midi'
}
# Words to ignore when trying to generate a folder name from a title
FOLDER_NAME_STOP_WORDS = {
"a", "alone", "am", "an", "and", "at", "be", "blues", "but", "by", "com",
"for", "grown", "hard", "he", "her", "his", "hitting", "i", "im", "in", "is", "it", "its",
"me", "much", "my", "net", "not", "of", "on", "or", "org", "our", "please",
"right", "s", "she", "so", "technically", "tell", "the", "their", "they", "this",
"to", "ve", "was", "we", "well", "were", "with", "www", "year", "you", "your",
}
# --- File and Folder Name Utilities ---
def clean_folder_name(name):
"""
Sanitizes a string to make it a valid folder name.
Removes invalid characters and trims whitespace.
Args:
name (str): The input string.
Returns:
str: A sanitized, valid folder name.
"""
if not isinstance(name, str):
name = str(name)
# Remove characters that are invalid in folder names on most OS
cleaned = re.sub(r'[<>:"/\\|?*]', '', name)
cleaned = cleaned.strip()
# Replace multiple spaces with a single space
cleaned = re.sub(r'\s+', ' ', cleaned)
# If after cleaning the name is empty, provide a default
if not cleaned:
return "untitled_folder"
# Truncate to a reasonable length
if len(cleaned) > MAX_FILENAME_COMPONENT_LENGTH:
cleaned = cleaned[:MAX_FILENAME_COMPONENT_LENGTH]
# Remove trailing dots or spaces, which can be problematic
cleaned = cleaned.rstrip('. ')
return cleaned if cleaned else "untitled_folder"
def clean_filename(name):
"""
Sanitizes a string to make it a valid file name.
Args:
name (str): The input string.
Returns:
str: A sanitized, valid file name.
"""
if not isinstance(name, str):
name = str(name)
cleaned = re.sub(r'[<>:"/\\|?*]', '_', name)
cleaned = cleaned.strip()
if not cleaned:
return "untitled_file"
base_name, ext = os.path.splitext(cleaned)
max_base_len = MAX_FILENAME_COMPONENT_LENGTH - len(ext)
if len(base_name) > max_base_len:
if max_base_len > 0:
base_name = base_name[:max_base_len]
else:
# Handle cases where the extension itself is too long
return cleaned[:MAX_FILENAME_COMPONENT_LENGTH]
return base_name + ext
# --- File Type Identification Functions ---
def is_image(filename):
"""Checks if a filename has a common image extension."""
if not filename: return False
_, ext = os.path.splitext(filename)
return ext.lower() in IMAGE_EXTENSIONS
def is_video(filename):
"""Checks if a filename has a common video extension."""
if not filename: return False
_, ext = os.path.splitext(filename)
return ext.lower() in VIDEO_EXTENSIONS
def is_zip(filename):
"""Checks if a filename is a .zip file."""
if not filename: return False
return filename.lower().endswith('.zip')
def is_rar(filename):
"""Checks if a filename is a .rar file."""
if not filename: return False
return filename.lower().endswith('.rar')
def is_archive(filename):
"""Checks if a filename has a common archive extension."""
if not filename: return False
_, ext = os.path.splitext(filename)
return ext.lower() in ARCHIVE_EXTENSIONS
def is_audio(filename):
"""Checks if a filename has a common audio extension."""
if not filename: return False
_, ext = os.path.splitext(filename)
return ext.lower() in AUDIO_EXTENSIONS

208
src/utils/network_utils.py Normal file
View File

@@ -0,0 +1,208 @@
# --- Standard Library Imports ---
import os
import re
from urllib.parse import urlparse
# --- Third-Party Library Imports ---
# This module might not require third-party libraries directly,
# but 'requests' is a common dependency for network operations.
# import requests
def parse_cookie_string(cookie_string):
"""
Parses a 'name=value; name2=value2' cookie string into a dictionary.
Args:
cookie_string (str): The cookie string from browser tools.
Returns:
dict or None: A dictionary of cookie names and values, or None if empty.
"""
cookies = {}
if cookie_string:
for item in cookie_string.split(';'):
parts = item.split('=', 1)
if len(parts) == 2:
name = parts[0].strip()
value = parts[1].strip()
if name:
cookies[name] = value
return cookies if cookies else None
def load_cookies_from_netscape_file(filepath, logger_func, target_domain_filter=None):
"""
Loads cookies from a Netscape-formatted cookies.txt file.
If a target_domain_filter is provided, only cookies for that domain
(or its subdomains) are returned.
Args:
filepath (str): The full path to the cookies.txt file.
logger_func (callable): Function to use for logging.
target_domain_filter (str, optional): The domain to filter cookies for.
Returns:
dict or None: A dictionary of cookie names and values, or None if none are loaded.
"""
cookies = {}
try:
with open(filepath, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split('\t')
if len(parts) == 7:
cookie_domain = parts[0]
name = parts[5]
value = parts[6]
if not name:
continue
if target_domain_filter:
# Match domain exactly or as a subdomain
host_to_match = target_domain_filter.lower()
cookie_domain_norm = cookie_domain.lower()
if (cookie_domain_norm.startswith('.') and host_to_match.endswith(cookie_domain_norm)) or \
(host_to_match == cookie_domain_norm):
cookies[name] = value
else:
cookies[name] = value
logger_func(f" 🍪 Loaded {len(cookies)} cookies from '{os.path.basename(filepath)}' for domain '{target_domain_filter or 'any'}'.")
return cookies if cookies else None
except FileNotFoundError:
logger_func(f" 🍪 Cookie file '{os.path.basename(filepath)}' not found.")
return None
except Exception as e:
logger_func(f" 🍪 Error parsing cookie file '{os.path.basename(filepath)}': {e}")
return None
def prepare_cookies_for_request(use_cookie_flag, cookie_text_input, selected_cookie_file_path, app_base_dir, logger_func, target_domain=None):
"""
Prepares a cookie dictionary from various sources based on user settings.
Priority:
1. UI-selected file path.
2. Domain-specific file in the app directory.
3. Default `cookies.txt` in the app directory.
4. Manually entered cookie text.
Args:
use_cookie_flag (bool): Whether cookies are enabled in the UI.
cookie_text_input (str): The raw text from the cookie input field.
selected_cookie_file_path (str): The path to a user-browsed cookie file.
app_base_dir (str): The base directory of the application.
logger_func (callable): Function for logging.
target_domain (str, optional): The domain for which cookies are needed.
Returns:
dict or None: A dictionary of cookies for the request, or None.
"""
if not use_cookie_flag:
return None
# Priority 1: Use the specifically browsed file first
if selected_cookie_file_path and os.path.exists(selected_cookie_file_path):
cookies = load_cookies_from_netscape_file(selected_cookie_file_path, logger_func, target_domain)
if cookies:
return cookies
# Priority 2: Look for a domain-specific cookie file
if app_base_dir and target_domain:
domain_specific_path = os.path.join(app_base_dir, "data", f"{target_domain}_cookies.txt")
if os.path.exists(domain_specific_path):
cookies = load_cookies_from_netscape_file(domain_specific_path, logger_func, target_domain)
if cookies:
return cookies
# Priority 3: Look for a generic cookies.txt
if app_base_dir:
default_path = os.path.join(app_base_dir, "appdata", "cookies.txt")
if os.path.exists(default_path):
cookies = load_cookies_from_netscape_file(default_path, logger_func, target_domain)
if cookies:
return cookies
# Priority 4: Fall back to manually entered text
if cookie_text_input:
cookies = parse_cookie_string(cookie_text_input)
if cookies:
return cookies
logger_func(f" 🍪 Cookie usage enabled for '{target_domain or 'any'}', but no valid cookies found.")
return None
def extract_post_info(url_string):
"""
Parses a URL string to extract the service, user ID, and post ID.
Args:
url_string (str): The URL to parse.
Returns:
tuple: A tuple containing (service, user_id, post_id). Any can be None.
"""
if not isinstance(url_string, str) or not url_string.strip():
return None, None, None
try:
parsed_url = urlparse(url_string.strip())
path_parts = [part for part in parsed_url.path.strip('/').split('/') if part]
# Standard format: /<service>/user/<user_id>/post/<post_id>
if len(path_parts) >= 3 and path_parts[1].lower() == 'user':
service = path_parts[0]
user_id = path_parts[2]
post_id = path_parts[4] if len(path_parts) >= 5 and path_parts[3].lower() == 'post' else None
return service, user_id, post_id
# API format: /api/v1/<service>/user/<user_id>...
if len(path_parts) >= 5 and path_parts[0:2] == ['api', 'v1'] and path_parts[3].lower() == 'user':
service = path_parts[2]
user_id = path_parts[4]
post_id = path_parts[6] if len(path_parts) >= 7 and path_parts[5].lower() == 'post' else None
return service, user_id, post_id
except Exception as e:
print(f"Debug: Exception during URL parsing for '{url_string}': {e}")
return None, None, None
def get_link_platform(url):
"""
Identifies the platform of a given URL based on its domain.
Args:
url (str): The URL to identify.
Returns:
str: The name of the platform (e.g., 'mega', 'google drive') or 'external'.
"""
try:
domain = urlparse(url).netloc.lower()
if 'drive.google.com' in domain: return 'google drive'
if 'mega.nz' in domain or 'mega.io' in domain: return 'mega'
if 'dropbox.com' in domain: return 'dropbox'
if 'patreon.com' in domain: return 'patreon'
if 'gofile.io' in domain: return 'gofile'
if 'instagram.com' in domain: return 'instagram'
if 'twitter.com' in domain or 'x.com' in domain: return 'twitter/x'
if 'discord.gg' in domain or 'discord.com/invite' in domain: return 'discord invite'
if 'pixiv.net' in domain: return 'pixiv'
if 'kemono.su' in domain or 'kemono.party' in domain: return 'kemono'
if 'coomer.su' in domain or 'coomer.party' in domain: return 'coomer'
# Fallback to a generic name for other domains
parts = domain.split('.')
if len(parts) >= 2:
return parts[-2]
return 'external'
except Exception:
return 'unknown'

587
src/utils/resolution.py Normal file
View File

@@ -0,0 +1,587 @@
# src/ui/utils/resolution.py
# --- Standard Library Imports ---
import os
# --- PyQt5 Imports ---
from PyQt5.QtWidgets import (
QSplitter, QScrollArea, QFrame, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QStackedWidget, QButtonGroup, QRadioButton, QCheckBox,
QListWidget, QTextEdit, QApplication
)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIntValidator, QFont # <-- Import QFont
# --- Local Application Imports ---
# Assuming execution from project root
from ..config.constants import *
def setup_ui(main_app):
"""
Initializes and scales the user interface for the DownloaderApp.
Args:
main_app: The instance of the main DownloaderApp.
"""
scale = float(main_app.settings.value(UI_SCALE_KEY, 1.0))
main_app.scale_factor = scale
default_font = QApplication.font()
base_font_size = 9 # Use a standard base size
default_font.setPointSize(int(base_font_size * scale))
main_app.setFont(default_font)
default_font = QApplication.font()
base_font_size = 9 # Use a standard base size
default_font.setPointSize(int(base_font_size * scale))
main_app.setFont(default_font)
# --- END: Improved Scaling Logic ---
main_app.main_splitter = QSplitter(Qt.Horizontal)
# --- Use a scroll area for the left panel for consistency ---
left_scroll_area = QScrollArea()
left_scroll_area.setWidgetResizable(True)
left_scroll_area.setFrameShape(QFrame.NoFrame)
left_panel_widget = QWidget()
left_layout = QVBoxLayout(left_panel_widget)
left_scroll_area.setWidget(left_panel_widget)
right_panel_widget = QWidget()
right_layout = QVBoxLayout(right_panel_widget)
left_layout.setContentsMargins(10, 10, 10, 10)
right_layout.setContentsMargins(10, 10, 10, 10)
apply_theme_to_app(main_app, main_app.current_theme, initial_load=True)
# --- URL and Page Range ---
main_app.url_input_widget = QWidget()
url_input_layout = QHBoxLayout(main_app.url_input_widget)
url_input_layout.setContentsMargins(0, 0, 0, 0)
main_app.url_label_widget = QLabel()
url_input_layout.addWidget(main_app.url_label_widget)
main_app.link_input = QLineEdit()
main_app.link_input.setPlaceholderText("e.g., https://kemono.su/patreon/user/12345 or .../post/98765")
main_app.link_input.textChanged.connect(main_app.update_custom_folder_visibility)
url_input_layout.addWidget(main_app.link_input, 1)
main_app.empty_popup_button = QPushButton("🎨")
special_font_size = int(9.5 * scale)
main_app.empty_popup_button.setStyleSheet(f"""
padding: {4*scale}px {6*scale}px;
font-size: {special_font_size}pt;
""")
main_app.empty_popup_button.clicked.connect(main_app._show_empty_popup)
url_input_layout.addWidget(main_app.empty_popup_button)
main_app.page_range_label = QLabel(main_app._tr("page_range_label_text", "Page Range:"))
main_app.page_range_label.setStyleSheet("font-weight: bold; padding-left: 10px;")
url_input_layout.addWidget(main_app.page_range_label)
main_app.start_page_input = QLineEdit()
main_app.start_page_input.setPlaceholderText(main_app._tr("start_page_input_placeholder", "Start"))
main_app.start_page_input.setFixedWidth(int(50 * scale))
main_app.start_page_input.setValidator(QIntValidator(1, 99999))
url_input_layout.addWidget(main_app.start_page_input)
main_app.to_label = QLabel(main_app._tr("page_range_to_label_text", "to"))
url_input_layout.addWidget(main_app.to_label)
main_app.end_page_input = QLineEdit()
main_app.end_page_input.setPlaceholderText(main_app._tr("end_page_input_placeholder", "End"))
main_app.end_page_input.setFixedWidth(int(50 * scale))
main_app.end_page_input.setToolTip(main_app._tr("end_page_input_tooltip", "For creator URLs: Specify the ending page number..."))
main_app.end_page_input.setValidator(QIntValidator(1, 99999))
url_input_layout.addWidget(main_app.end_page_input)
main_app.url_placeholder_widget = QWidget()
placeholder_layout = QHBoxLayout(main_app.url_placeholder_widget)
placeholder_layout.setContentsMargins(0, 0, 0, 0)
main_app.fav_mode_active_label = QLabel(main_app._tr("fav_mode_active_label_text", "⭐ Favorite Mode is active..."))
main_app.fav_mode_active_label.setAlignment(Qt.AlignCenter)
placeholder_layout.addWidget(main_app.fav_mode_active_label)
main_app.url_or_placeholder_stack = QStackedWidget()
main_app.url_or_placeholder_stack.addWidget(main_app.url_input_widget)
main_app.url_or_placeholder_stack.addWidget(main_app.url_placeholder_widget)
left_layout.addWidget(main_app.url_or_placeholder_stack)
# --- Download Location ---
main_app.download_location_label_widget = QLabel()
left_layout.addWidget(main_app.download_location_label_widget)
dir_layout = QHBoxLayout()
main_app.dir_input = QLineEdit()
main_app.dir_input.setPlaceholderText("Select folder where downloads will be saved")
main_app.dir_button = QPushButton("Browse...")
main_app.dir_button.clicked.connect(main_app.browse_directory)
dir_layout.addWidget(main_app.dir_input, 1)
dir_layout.addWidget(main_app.dir_button)
left_layout.addLayout(dir_layout)
# --- Filters and Custom Folder Container ---
main_app.filters_and_custom_folder_container_widget = QWidget()
filters_and_custom_folder_layout = QHBoxLayout(main_app.filters_and_custom_folder_container_widget)
filters_and_custom_folder_layout.setContentsMargins(0, 5, 0, 0)
filters_and_custom_folder_layout.setSpacing(10)
main_app.character_filter_widget = QWidget()
character_filter_v_layout = QVBoxLayout(main_app.character_filter_widget)
character_filter_v_layout.setContentsMargins(0, 0, 0, 0)
character_filter_v_layout.setSpacing(2)
main_app.character_label = QLabel("🎯 Filter by Character(s) (comma-separated):")
character_filter_v_layout.addWidget(main_app.character_label)
char_input_and_button_layout = QHBoxLayout()
char_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
char_input_and_button_layout.setSpacing(10)
main_app.character_input = QLineEdit()
main_app.character_input.setPlaceholderText("e.g., Tifa, Aerith, (Cloud, Zack)")
char_input_and_button_layout.addWidget(main_app.character_input, 3)
main_app.char_filter_scope_toggle_button = QPushButton()
main_app._update_char_filter_scope_button_text()
char_input_and_button_layout.addWidget(main_app.char_filter_scope_toggle_button, 1)
character_filter_v_layout.addLayout(char_input_and_button_layout)
# --- Custom Folder Widget Definition ---
main_app.custom_folder_widget = QWidget()
custom_folder_v_layout = QVBoxLayout(main_app.custom_folder_widget)
custom_folder_v_layout.setContentsMargins(0, 0, 0, 0)
custom_folder_v_layout.setSpacing(2)
main_app.custom_folder_label = QLabel("🗄️ Custom Folder Name (Single Post Only):")
main_app.custom_folder_input = QLineEdit()
main_app.custom_folder_input.setPlaceholderText("Optional: Save this post to specific folder")
custom_folder_v_layout.addWidget(main_app.custom_folder_label)
custom_folder_v_layout.addWidget(main_app.custom_folder_input)
main_app.custom_folder_widget.setVisible(False)
filters_and_custom_folder_layout.addWidget(main_app.character_filter_widget, 1)
filters_and_custom_folder_layout.addWidget(main_app.custom_folder_widget, 1)
left_layout.addWidget(main_app.filters_and_custom_folder_container_widget)
# --- Word Manipulation Container ---
word_manipulation_container_widget = QWidget()
word_manipulation_outer_layout = QHBoxLayout(word_manipulation_container_widget)
word_manipulation_outer_layout.setContentsMargins(0, 0, 0, 0)
word_manipulation_outer_layout.setSpacing(15)
skip_words_widget = QWidget()
skip_words_vertical_layout = QVBoxLayout(skip_words_widget)
skip_words_vertical_layout.setContentsMargins(0, 0, 0, 0)
skip_words_vertical_layout.setSpacing(2)
main_app.skip_words_label_widget = QLabel()
skip_words_vertical_layout.addWidget(main_app.skip_words_label_widget)
skip_input_and_button_layout = QHBoxLayout()
skip_input_and_button_layout.setContentsMargins(0, 0, 0, 0)
skip_input_and_button_layout.setSpacing(10)
main_app.skip_words_input = QLineEdit()
main_app.skip_words_input.setPlaceholderText("e.g., WM, WIP, sketch, preview")
skip_input_and_button_layout.addWidget(main_app.skip_words_input, 1)
main_app.skip_scope_toggle_button = QPushButton()
main_app._update_skip_scope_button_text()
skip_input_and_button_layout.addWidget(main_app.skip_scope_toggle_button, 0)
skip_words_vertical_layout.addLayout(skip_input_and_button_layout)
word_manipulation_outer_layout.addWidget(skip_words_widget, 7)
remove_words_widget = QWidget()
remove_words_vertical_layout = QVBoxLayout(remove_words_widget)
remove_words_vertical_layout.setContentsMargins(0, 0, 0, 0)
remove_words_vertical_layout.setSpacing(2)
main_app.remove_from_filename_label_widget = QLabel()
remove_words_vertical_layout.addWidget(main_app.remove_from_filename_label_widget)
main_app.remove_from_filename_input = QLineEdit()
main_app.remove_from_filename_input.setPlaceholderText("e.g., patreon, HD")
remove_words_vertical_layout.addWidget(main_app.remove_from_filename_input)
word_manipulation_outer_layout.addWidget(remove_words_widget, 3)
left_layout.addWidget(word_manipulation_container_widget)
# --- File Filter Layout ---
file_filter_layout = QVBoxLayout()
file_filter_layout.setContentsMargins(0, 10, 0, 0)
file_filter_layout.addWidget(QLabel("Filter Files:"))
radio_button_layout = QHBoxLayout()
radio_button_layout.setSpacing(10)
main_app.radio_group = QButtonGroup(main_app)
main_app.radio_all = QRadioButton("All")
main_app.radio_images = QRadioButton("Images/GIFs")
main_app.radio_videos = QRadioButton("Videos")
main_app.radio_only_archives = QRadioButton("📦 Only Archives")
main_app.radio_only_audio = QRadioButton("🎧 Only Audio")
main_app.radio_only_links = QRadioButton("🔗 Only Links")
main_app.radio_more = QRadioButton("More")
main_app.radio_all.setChecked(True)
for btn in [main_app.radio_all, main_app.radio_images, main_app.radio_videos, main_app.radio_only_archives, main_app.radio_only_audio, main_app.radio_only_links, main_app.radio_more]:
main_app.radio_group.addButton(btn)
radio_button_layout.addWidget(btn)
main_app.favorite_mode_checkbox = QCheckBox()
main_app.favorite_mode_checkbox.setChecked(False)
radio_button_layout.addWidget(main_app.favorite_mode_checkbox)
radio_button_layout.addStretch(1)
file_filter_layout.addLayout(radio_button_layout)
left_layout.addLayout(file_filter_layout)
# --- Checkboxes Group ---
checkboxes_group_layout = QVBoxLayout()
checkboxes_group_layout.setSpacing(10)
row1_layout = QHBoxLayout()
row1_layout.setSpacing(10)
main_app.skip_zip_checkbox = QCheckBox("Skip archives")
main_app.skip_zip_checkbox.setToolTip("Skip Common Archives (Eg.. Zip, Rar, 7z)")
main_app.skip_zip_checkbox.setChecked(True)
row1_layout.addWidget(main_app.skip_zip_checkbox)
main_app.download_thumbnails_checkbox = QCheckBox("Download Thumbnails Only")
row1_layout.addWidget(main_app.download_thumbnails_checkbox)
main_app.scan_content_images_checkbox = QCheckBox("Scan Content for Images")
main_app.scan_content_images_checkbox.setChecked(main_app.scan_content_images_setting)
row1_layout.addWidget(main_app.scan_content_images_checkbox)
main_app.compress_images_checkbox = QCheckBox("Compress to WebP")
main_app.compress_images_checkbox.setToolTip("Compress images > 1.5MB to WebP format (requires Pillow).")
row1_layout.addWidget(main_app.compress_images_checkbox)
main_app.keep_duplicates_checkbox = QCheckBox("Keep Duplicates")
main_app.keep_duplicates_checkbox.setToolTip("If checked, downloads all files from a post even if they have the same name.")
row1_layout.addWidget(main_app.keep_duplicates_checkbox)
row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(row1_layout)
# --- Advanced Settings ---
advanced_settings_label = QLabel("⚙️ Advanced Settings:")
checkboxes_group_layout.addWidget(advanced_settings_label)
advanced_row1_layout = QHBoxLayout()
advanced_row1_layout.setSpacing(10)
# --- REORDERED CHECKBOXES ---
main_app.use_subfolder_per_post_checkbox = QCheckBox("Subfolder per Post")
main_app.use_subfolder_per_post_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
main_app.use_subfolder_per_post_checkbox.setChecked(True)
advanced_row1_layout.addWidget(main_app.use_subfolder_per_post_checkbox)
main_app.date_prefix_checkbox = QCheckBox("Date Prefix")
main_app.date_prefix_checkbox.setToolTip("When 'Subfolder per Post' is active, prefix the folder name with the post's upload date.")
advanced_row1_layout.addWidget(main_app.date_prefix_checkbox)
main_app.use_subfolders_checkbox = QCheckBox("Separate Folders by Known.txt")
main_app.use_subfolders_checkbox.setChecked(False)
main_app.use_subfolders_checkbox.toggled.connect(main_app.update_ui_for_subfolders)
advanced_row1_layout.addWidget(main_app.use_subfolders_checkbox)
# --- END REORDER ---
main_app.use_cookie_checkbox = QCheckBox("Use Cookie")
main_app.use_cookie_checkbox.setChecked(main_app.use_cookie_setting)
main_app.cookie_text_input = QLineEdit()
main_app.cookie_text_input.setPlaceholderText("if no Select cookies.txt)")
main_app.cookie_text_input.setText(main_app.cookie_text_setting)
advanced_row1_layout.addWidget(main_app.use_cookie_checkbox)
advanced_row1_layout.addWidget(main_app.cookie_text_input, 2)
main_app.cookie_browse_button = QPushButton("Browse...")
main_app.cookie_browse_button.setFixedWidth(int(80 * scale))
advanced_row1_layout.addWidget(main_app.cookie_browse_button)
advanced_row1_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row1_layout)
advanced_row2_layout = QHBoxLayout()
advanced_row2_layout.setSpacing(10)
multithreading_layout = QHBoxLayout()
multithreading_layout.setContentsMargins(0, 0, 0, 0)
main_app.use_multithreading_checkbox = QCheckBox("Use Multithreading")
main_app.use_multithreading_checkbox.setChecked(True)
multithreading_layout.addWidget(main_app.use_multithreading_checkbox)
main_app.thread_count_label = QLabel("Threads:")
multithreading_layout.addWidget(main_app.thread_count_label)
main_app.thread_count_input = QLineEdit("4")
main_app.thread_count_input.setFixedWidth(int(40 * scale))
main_app.thread_count_input.setValidator(QIntValidator(1, MAX_THREADS))
multithreading_layout.addWidget(main_app.thread_count_input)
advanced_row2_layout.addLayout(multithreading_layout)
main_app.external_links_checkbox = QCheckBox("Show External Links in Log")
advanced_row2_layout.addWidget(main_app.external_links_checkbox)
main_app.manga_mode_checkbox = QCheckBox("Manga/Comic Mode")
advanced_row2_layout.addWidget(main_app.manga_mode_checkbox)
advanced_row2_layout.addStretch(1)
checkboxes_group_layout.addLayout(advanced_row2_layout)
left_layout.addLayout(checkboxes_group_layout)
# --- Action Buttons ---
main_app.standard_action_buttons_widget = QWidget()
btn_layout = QHBoxLayout(main_app.standard_action_buttons_widget)
btn_layout.setContentsMargins(0, 10, 0, 0)
btn_layout.setSpacing(10)
main_app.download_btn = QPushButton("⬇️ Start Download")
font = main_app.download_btn.font()
font.setBold(True)
main_app.download_btn.setFont(font)
main_app.download_btn.clicked.connect(main_app.start_download)
main_app.pause_btn = QPushButton("⏸️ Pause Download")
main_app.pause_btn.setEnabled(False)
main_app.pause_btn.clicked.connect(main_app._handle_pause_resume_action)
main_app.cancel_btn = QPushButton("❌ Cancel & Reset UI")
main_app.cancel_btn.setEnabled(False)
main_app.cancel_btn.clicked.connect(main_app.cancel_download_button_action)
main_app.error_btn = QPushButton("Error")
main_app.error_btn.setToolTip("View files skipped due to errors and optionally retry them.")
main_app.error_btn.setEnabled(True)
btn_layout.addWidget(main_app.download_btn)
btn_layout.addWidget(main_app.pause_btn)
btn_layout.addWidget(main_app.cancel_btn)
btn_layout.addWidget(main_app.error_btn)
main_app.favorite_action_buttons_widget = QWidget()
favorite_buttons_layout = QHBoxLayout(main_app.favorite_action_buttons_widget)
main_app.favorite_mode_artists_button = QPushButton("🖼️ Favorite Artists")
main_app.favorite_mode_posts_button = QPushButton("📄 Favorite Posts")
main_app.favorite_scope_toggle_button = QPushButton()
favorite_buttons_layout.addWidget(main_app.favorite_mode_artists_button)
favorite_buttons_layout.addWidget(main_app.favorite_mode_posts_button)
favorite_buttons_layout.addWidget(main_app.favorite_scope_toggle_button)
main_app.bottom_action_buttons_stack = QStackedWidget()
main_app.bottom_action_buttons_stack.addWidget(main_app.standard_action_buttons_widget)
main_app.bottom_action_buttons_stack.addWidget(main_app.favorite_action_buttons_widget)
left_layout.addWidget(main_app.bottom_action_buttons_stack)
left_layout.addSpacing(10)
# --- Known Names Layout ---
known_chars_label_layout = QHBoxLayout()
known_chars_label_layout.setSpacing(10)
main_app.known_chars_label = QLabel("🎭 Known Shows/Characters (for Folder Names):")
known_chars_label_layout.addWidget(main_app.known_chars_label)
main_app.open_known_txt_button = QPushButton("Open Known.txt")
main_app.open_known_txt_button.setFixedWidth(int(120 * scale))
known_chars_label_layout.addWidget(main_app.open_known_txt_button)
main_app.character_search_input = QLineEdit()
main_app.character_search_input.setPlaceholderText("Search characters...")
known_chars_label_layout.addWidget(main_app.character_search_input, 1)
left_layout.addLayout(known_chars_label_layout)
main_app.character_list = QListWidget()
main_app.character_list.setSelectionMode(QListWidget.ExtendedSelection)
left_layout.addWidget(main_app.character_list, 1)
char_manage_layout = QHBoxLayout()
char_manage_layout.setSpacing(10)
main_app.new_char_input = QLineEdit()
main_app.new_char_input.setPlaceholderText("Add new show/character name")
main_app.add_char_button = QPushButton(" Add")
main_app.add_to_filter_button = QPushButton("⤵️ Add to Filter")
main_app.add_to_filter_button.setToolTip("Select names... to add to the 'Filter by Character(s)' field.")
main_app.delete_char_button = QPushButton("🗑️ Delete Selected")
main_app.delete_char_button.setToolTip("Delete the selected name(s)...")
main_app.add_char_button.clicked.connect(main_app._handle_ui_add_new_character)
main_app.new_char_input.returnPressed.connect(main_app.add_char_button.click)
main_app.delete_char_button.clicked.connect(main_app.delete_selected_character)
char_manage_layout.addWidget(main_app.new_char_input, 2)
char_manage_layout.addWidget(main_app.add_char_button, 0)
main_app.known_names_help_button = QPushButton("?")
main_app.known_names_help_button.setFixedWidth(int(45 * scale))
main_app.known_names_help_button.clicked.connect(main_app._show_feature_guide)
main_app.history_button = QPushButton("📜")
main_app.history_button.setFixedWidth(int(45 * scale))
main_app.history_button.setToolTip(main_app._tr("history_button_tooltip_text", "View download history"))
main_app.future_settings_button = QPushButton("⚙️")
main_app.future_settings_button.setFixedWidth(int(45 * scale))
main_app.future_settings_button.clicked.connect(main_app._show_future_settings_dialog)
main_app.support_button = QPushButton("❤️ Support")
main_app.support_button.setFixedWidth(int(100 * scale))
main_app.support_button.setToolTip("Support the application developer.")
char_manage_layout.addWidget(main_app.add_to_filter_button, 1)
char_manage_layout.addWidget(main_app.delete_char_button, 1)
char_manage_layout.addWidget(main_app.known_names_help_button, 0)
char_manage_layout.addWidget(main_app.history_button, 0)
char_manage_layout.addWidget(main_app.future_settings_button, 0)
char_manage_layout.addWidget(main_app.support_button, 0)
left_layout.addLayout(char_manage_layout)
left_layout.addStretch(0)
# --- Right Panel (Logs) ---
right_panel_widget.setLayout(right_layout)
log_title_layout = QHBoxLayout()
main_app.progress_log_label = QLabel("📜 Progress Log:")
log_title_layout.addWidget(main_app.progress_log_label)
log_title_layout.addStretch(1)
main_app.link_search_input = QLineEdit()
main_app.link_search_input.setPlaceholderText("Search Links...")
main_app.link_search_input.setVisible(False)
log_title_layout.addWidget(main_app.link_search_input)
main_app.link_search_button = QPushButton("<EFBFBD>")
main_app.link_search_button.setVisible(False)
main_app.link_search_button.setFixedWidth(int(30 * scale))
log_title_layout.addWidget(main_app.link_search_button)
main_app.manga_rename_toggle_button = QPushButton()
main_app.manga_rename_toggle_button.setVisible(False)
main_app.manga_rename_toggle_button.setFixedWidth(int(140 * scale))
main_app._update_manga_filename_style_button_text()
log_title_layout.addWidget(main_app.manga_rename_toggle_button)
main_app.manga_date_prefix_input = QLineEdit()
main_app.manga_date_prefix_input.setPlaceholderText("Prefix for Manga Filenames")
main_app.manga_date_prefix_input.setVisible(False)
log_title_layout.addWidget(main_app.manga_date_prefix_input)
main_app.multipart_toggle_button = QPushButton()
main_app.multipart_toggle_button.setToolTip("Toggle between Multi-part and Single-stream downloads for large files.")
main_app.multipart_toggle_button.setFixedWidth(int(130 * scale))
main_app._update_multipart_toggle_button_text()
log_title_layout.addWidget(main_app.multipart_toggle_button)
main_app.EYE_ICON = "\U0001F441"
main_app.CLOSED_EYE_ICON = "\U0001F648"
main_app.log_verbosity_toggle_button = QPushButton(main_app.EYE_ICON)
main_app.log_verbosity_toggle_button.setFixedWidth(int(45 * scale))
main_app.log_verbosity_toggle_button.setStyleSheet(f"font-size: {11 * scale}pt; padding: {4 * scale}px {2 * scale}px;")
log_title_layout.addWidget(main_app.log_verbosity_toggle_button)
main_app.reset_button = QPushButton("🔄 Reset")
main_app.reset_button.setFixedWidth(int(80 * scale))
log_title_layout.addWidget(main_app.reset_button)
right_layout.addLayout(log_title_layout)
main_app.log_splitter = QSplitter(Qt.Vertical)
main_app.log_view_stack = QStackedWidget()
main_app.main_log_output = QTextEdit()
main_app.main_log_output.setReadOnly(True)
main_app.main_log_output.setLineWrapMode(QTextEdit.NoWrap)
main_app.log_view_stack.addWidget(main_app.main_log_output)
main_app.missed_character_log_output = QTextEdit()
main_app.missed_character_log_output.setReadOnly(True)
main_app.missed_character_log_output.setLineWrapMode(QTextEdit.NoWrap)
main_app.log_view_stack.addWidget(main_app.missed_character_log_output)
main_app.external_log_output = QTextEdit()
main_app.external_log_output.setReadOnly(True)
main_app.external_log_output.setLineWrapMode(QTextEdit.NoWrap)
main_app.external_log_output.hide()
main_app.log_splitter.addWidget(main_app.log_view_stack)
main_app.log_splitter.addWidget(main_app.external_log_output)
main_app.log_splitter.setSizes([main_app.height(), 0])
right_layout.addWidget(main_app.log_splitter, 1)
export_button_layout = QHBoxLayout()
export_button_layout.addStretch(1)
main_app.export_links_button = QPushButton(main_app._tr("export_links_button_text", "Export Links"))
main_app.export_links_button.setFixedWidth(int(100 * scale))
main_app.export_links_button.setEnabled(False)
main_app.export_links_button.setVisible(False)
export_button_layout.addWidget(main_app.export_links_button)
main_app.download_extracted_links_button = QPushButton(main_app._tr("download_extracted_links_button_text", "Download"))
main_app.download_extracted_links_button.setFixedWidth(int(100 * scale))
main_app.download_extracted_links_button.setEnabled(False)
main_app.download_extracted_links_button.setVisible(False)
export_button_layout.addWidget(main_app.download_extracted_links_button)
main_app.log_display_mode_toggle_button = QPushButton()
main_app.log_display_mode_toggle_button.setFixedWidth(int(120 * scale))
main_app.log_display_mode_toggle_button.setVisible(False)
export_button_layout.addWidget(main_app.log_display_mode_toggle_button)
right_layout.addLayout(export_button_layout)
main_app.progress_label = QLabel("Progress: Idle")
main_app.progress_label.setStyleSheet("padding-top: 5px; font-style: italic;")
right_layout.addWidget(main_app.progress_label)
main_app.file_progress_label = QLabel("")
main_app.file_progress_label.setToolTip("Shows the progress of individual file downloads, including speed and size.")
main_app.file_progress_label.setWordWrap(True)
main_app.file_progress_label.setStyleSheet("padding-top: 2px; font-style: italic; color: #A0A0A0;")
right_layout.addWidget(main_app.file_progress_label)
# --- Final Assembly ---
main_app.main_splitter.addWidget(left_scroll_area)
main_app.main_splitter.addWidget(right_panel_widget)
if main_app.width() >= 1920:
# For wider resolutions, give more space to the log panel (right).
main_app.main_splitter.setStretchFactor(0, 4)
main_app.main_splitter.setStretchFactor(1, 6)
else:
# Default for lower resolutions, giving more space to controls (left).
main_app.main_splitter.setStretchFactor(0, 7)
main_app.main_splitter.setStretchFactor(1, 3)
top_level_layout = QHBoxLayout(main_app)
top_level_layout.setContentsMargins(0, 0, 0, 0)
top_level_layout.addWidget(main_app.main_splitter)
# --- Initial UI State Updates ---
main_app.update_ui_for_subfolders(main_app.use_subfolders_checkbox.isChecked())
main_app.update_external_links_setting(main_app.external_links_checkbox.isChecked())
main_app.update_multithreading_label(main_app.thread_count_input.text())
main_app.update_page_range_enabled_state()
if main_app.manga_mode_checkbox:
main_app.update_ui_for_manga_mode(main_app.manga_mode_checkbox.isChecked())
if hasattr(main_app, 'link_input'):
main_app.link_input.textChanged.connect(lambda: main_app.update_ui_for_manga_mode(main_app.manga_mode_checkbox.isChecked() if main_app.manga_mode_checkbox else False))
main_app._load_creator_name_cache_from_json()
main_app.load_known_names_from_util()
main_app._update_cookie_input_visibility(main_app.use_cookie_checkbox.isChecked() if hasattr(main_app, 'use_cookie_checkbox') else False)
main_app._handle_multithreading_toggle(main_app.use_multithreading_checkbox.isChecked())
if hasattr(main_app, 'radio_group') and main_app.radio_group.checkedButton():
main_app._handle_filter_mode_change(main_app.radio_group.checkedButton(), True)
main_app.radio_group.buttonToggled.connect(main_app._handle_more_options_toggled)
main_app._update_manga_filename_style_button_text()
main_app._update_skip_scope_button_text()
main_app._update_char_filter_scope_button_text()
main_app._update_multithreading_for_date_mode()
if hasattr(main_app, 'download_thumbnails_checkbox'):
main_app._handle_thumbnail_mode_change(main_app.download_thumbnails_checkbox.isChecked())
if hasattr(main_app, 'favorite_mode_checkbox'):
main_app._handle_favorite_mode_toggle(False)
def get_dark_theme(scale=1):
"""
Generates the stylesheet for the dark theme, scaled by the given factor.
"""
# Adjust base font size for better readability
font_size_base = 9.5
font_size_small_base = 8.5
# Apply scaling
font_size = int(font_size_base * scale)
font_size_small = int(font_size_small_base * scale)
line_edit_padding = int(5 * scale)
button_padding_v = int(5 * scale)
button_padding_h = int(12 * scale)
tooltip_padding = int(4 * scale)
indicator_size = int(14 * scale)
return f"""
QWidget {{
background-color: #2E2E2E;
color: #E0E0E0;
font-family: Segoe UI, Arial, sans-serif;
font-size: {font_size}pt;
}}
QLineEdit, QListWidget, QTextEdit {{
background-color: #3C3F41;
border: 1px solid #5A5A5A;
padding: {line_edit_padding}px;
color: #F0F0F0;
border-radius: 4px;
font-size: {font_size}pt;
}}
QTextEdit {{
font-family: Consolas, Courier New, monospace;
}}
QPushButton {{
background-color: #555;
color: #F0F0F0;
border: 1px solid #6A6A6A;
padding: {button_padding_v}px {button_padding_h}px;
border-radius: 4px;
}}
QPushButton:hover {{ background-color: #656565; border: 1px solid #7A7A7A; }}
QPushButton:pressed {{ background-color: #4A4A4A; }}
QPushButton:disabled {{ background-color: #404040; color: #888; border-color: #555; }}
QLabel {{ font-weight: bold; color: #C0C0C0; }}
QRadioButton, QCheckBox {{ spacing: {int(5 * scale)}px; color: #E0E0E0; }}
QRadioButton::indicator, QCheckBox::indicator {{ width: {indicator_size}px; height: {indicator_size}px; }}
QListWidget {{ alternate-background-color: #353535; }}
QListWidget::item:selected {{ background-color: #007ACC; color: #FFFFFF; }}
QToolTip {{
background-color: #4A4A4A;
color: #F0F0F0;
border: 1px solid #6A6A6A;
padding: {tooltip_padding}px;
border-radius: 3px;
font-size: {font_size}pt;
}}
QSplitter::handle {{ background-color: #5A5A5A; }}
QSplitter::handle:horizontal {{ width: {int(5 * scale)}px; }}
QSplitter::handle:vertical {{ height: {int(5 * scale)}px; }}
"""
def apply_theme_to_app(main_app, theme_name, initial_load=False):
"""
Applies the selected theme and scaling to the main application window.
"""
main_app.current_theme = theme_name
if not initial_load:
main_app.settings.setValue(THEME_KEY, theme_name)
main_app.settings.sync()
if theme_name == "dark":
scale = getattr(main_app, 'scale_factor', 1)
main_app.setStyleSheet(get_dark_theme(scale))
if not initial_load:
main_app.log_signal.emit("🎨 Switched to Dark Mode.")
else:
main_app.setStyleSheet("")
if not initial_load:
main_app.log_signal.emit("🎨 Switched to Light Mode.")
main_app.update()

207
src/utils/text_utils.py Normal file
View File

@@ -0,0 +1,207 @@
# --- Standard Library Imports ---
import re
import html
# --- Local Application Imports ---
# Import from file_utils within the same package
from .file_utils import clean_folder_name, FOLDER_NAME_STOP_WORDS
# --- Module Constants ---
# Regular expression patterns for cleaning up titles before matching against Known.txt
KNOWN_TXT_MATCH_CLEANUP_PATTERNS = [
r'\bcum\b',
r'\bnsfw\b',
r'\bsfw\b',
r'\bweb\b',
r'\bhd\b',
r'\bhi\s*res\b',
r'\bhigh\s*res\b',
r'\b\d+p\b',
r'\b\d+k\b',
r'\[OC\]',
r'\[Request(?:s)?\]',
r'\bCommission\b',
r'\bComm\b',
r'\bPreview\b',
]
# --- Text Matching and Manipulation Utilities ---
def is_title_match_for_character(post_title, character_name_filter):
"""
Checks if a post title contains a specific character name as a whole word.
Case-insensitive.
Args:
post_title (str): The title of the post.
character_name_filter (str): The character name to search for.
Returns:
bool: True if the name is found as a whole word, False otherwise.
"""
if not post_title or not character_name_filter:
return False
# Use word boundaries (\b) to match whole words only
pattern = r"(?i)\b" + re.escape(str(character_name_filter).strip()) + r"\b"
return bool(re.search(pattern, post_title))
def is_filename_match_for_character(filename, character_name_filter):
"""
Checks if a filename contains a character name. This is a simple substring check.
Case-insensitive.
Args:
filename (str): The name of the file.
character_name_filter (str): The character name to search for.
Returns:
bool: True if the substring is found, False otherwise.
"""
if not filename or not character_name_filter:
return False
return str(character_name_filter).strip().lower() in filename.lower()
def strip_html_tags(html_text):
"""
Removes HTML tags from a string and cleans up resulting whitespace.
Args:
html_text (str): The input string containing HTML.
Returns:
str: The text with HTML tags removed.
"""
if not html_text:
return ""
# First, unescape HTML entities like &amp; -> &
text = html.unescape(str(html_text))
# Remove all tags
text_after_tag_removal = re.sub(r'<[^>]+>', ' ', text)
# Replace multiple whitespace characters with a single space
cleaned_text = re.sub(r'\s+', ' ', text_after_tag_removal).strip()
return cleaned_text
def extract_folder_name_from_title(title, unwanted_keywords):
"""
Extracts a plausible folder name from a post title by finding the first
significant word that isn't a stop-word.
Args:
title (str): The post title.
unwanted_keywords (set): A set of words to ignore.
Returns:
str: The extracted folder name, or 'Uncategorized'.
"""
if not title:
return 'Uncategorized'
title_lower = title.lower()
# Find all whole words in the title
tokens = re.findall(r'\b[\w\-]+\b', title_lower)
for token in tokens:
clean_token = clean_folder_name(token)
if clean_token and clean_token.lower() not in unwanted_keywords:
return clean_token
# Fallback to cleaning the full title if no single significant word is found
cleaned_full_title = clean_folder_name(title)
return cleaned_full_title if cleaned_full_title else 'Uncategorized'
def match_folders_from_title(title, names_to_match, unwanted_keywords):
"""
Matches folder names from a title based on a list of known name objects.
Each name object is a dict: {'name': 'PrimaryName', 'aliases': ['alias1', ...]}
Args:
title (str): The post title to check.
names_to_match (list): A list of known name dictionaries.
unwanted_keywords (set): A set of folder names to ignore.
Returns:
list: A sorted list of matched primary folder names.
"""
if not title or not names_to_match:
return []
# Clean the title by removing common tags like [OC], [HD], etc.
cleaned_title = title
for pat_str in KNOWN_TXT_MATCH_CLEANUP_PATTERNS:
cleaned_title = re.sub(pat_str, ' ', cleaned_title, flags=re.IGNORECASE)
cleaned_title = re.sub(r'\s+', ' ', cleaned_title).strip()
title_lower = cleaned_title.lower()
matched_cleaned_names = set()
# Sort by name length descending to match longer names first (e.g., "Cloud Strife" before "Cloud")
sorted_name_objects = sorted(names_to_match, key=lambda x: len(x.get("name", "")), reverse=True)
for name_obj in sorted_name_objects:
primary_folder_name = name_obj.get("name")
aliases = name_obj.get("aliases", [])
if not primary_folder_name or not aliases:
continue
for alias in aliases:
alias_lower = alias.lower()
if not alias_lower: continue
# Use word boundaries for accurate matching
pattern = r'\b' + re.escape(alias_lower) + r'\b'
if re.search(pattern, title_lower):
cleaned_primary_name = clean_folder_name(primary_folder_name)
if cleaned_primary_name.lower() not in unwanted_keywords:
matched_cleaned_names.add(cleaned_primary_name)
break # Move to the next name object once a match is found for this one
return sorted(list(matched_cleaned_names))
def match_folders_from_filename_enhanced(filename, names_to_match, unwanted_keywords):
"""
Matches folder names from a filename, prioritizing longer and more specific aliases.
Args:
filename (str): The filename to check.
names_to_match (list): A list of known name dictionaries.
unwanted_keywords (set): A set of folder names to ignore.
Returns:
list: A sorted list of matched primary folder names.
"""
if not filename or not names_to_match:
return []
filename_lower = filename.lower()
matched_primary_names = set()
# Create a flat list of (alias, primary_name) tuples to sort by alias length
alias_map_to_primary = []
for name_obj in names_to_match:
primary_name = name_obj.get("name")
if not primary_name: continue
cleaned_primary_name = clean_folder_name(primary_name)
if not cleaned_primary_name or cleaned_primary_name.lower() in unwanted_keywords:
continue
for alias in name_obj.get("aliases", []):
if alias.lower():
alias_map_to_primary.append((alias.lower(), cleaned_primary_name))
# Sort by alias length, descending, to match longer aliases first
alias_map_to_primary.sort(key=lambda x: len(x[0]), reverse=True)
for alias_lower, primary_name_for_alias in alias_map_to_primary:
if filename_lower.startswith(alias_lower):
matched_primary_names.add(primary_name_for_alias)
return sorted(list(matched_primary_names))