• R/O
  • HTTP
  • SSH
  • HTTPS

Commit

Frequently used words (click to add to your profile)

javac++androidlinuxc#windowsobjective-ccocoa誰得qtpythonphprubygameguibathyscaphec計画中(planning stage)翻訳omegatframeworktwitterdomtestvb.netdirectxゲームエンジンbtronarduinopreviewer

D wrapper around (some) of the pixiv web API


Commit MetaInfo

Revision2fbaa590caa2e05189cdaee5429e46e8c1a1436c (tree)
Zeit2023-01-19 13:44:07
Autorsupercell <stigma@disr...>
Commitersupercell

Log Message

Big ol' re-write.

Ändern Zusammenfassung

Diff

--- a/README
+++ b/README
@@ -15,6 +15,13 @@ and change the dub.sdl file as written in the comments.
1515
1616 = usage =
1717
18+*NOTICE*: You need to know what QuantumDepth the GraphicsMagick library uses
19+on the system which will run this library. Having the incorrect configuration
20+_will_ result in runtime errors. To find this out, run
21+`gm version | head -n1 | cut -d' ' -f4`. Then, use the `--override-config`
22+option when compiling with dub or the "subConfiguration" setting in a dub
23+SDL/JSON file.
24+
1825 the basic usage is that you create a Client structure by passing your
1926 PHPSESSID cookie value (see PHPSESSID file for information on how to find this).
2027
--- a/dub.sdl
+++ b/dub.sdl
@@ -4,8 +4,10 @@ authors "supercell"
44 copyright "Copyright © 2022, supercell"
55 license "GPL-3.0"
66
7+systemDependencies "GraphicsMagick"
8+
79 dependency "magickd:graphicsmagick_c" repository="git+https://repo.or.cz/magickd.git" \
8- version="82221a99b4c74b2a88e0ea421b3d6f25b05d465f"
10+ version="8f77b94429c8d883ce4c22a2d4d6346296809b81"
911
1012 ######
1113 #
--- /dev/null
+++ b/dub.selections.json
@@ -0,0 +1,6 @@
1+{
2+ "fileVersion": 1,
3+ "versions": {
4+ "magickd": {"version":"8f77b94429c8d883ce4c22a2d4d6346296809b81","repository":"git+https://repo.or.cz/magickd.git"}
5+ }
6+}
--- a/source/pixivd.d
+++ /dev/null
@@ -1,802 +0,0 @@
1-/**
2- * Examples:
3- * ---
4- * import std.file : getcwd;
5- * import std.stdio : writefln;
6- *
7- * import pixivd;
8- *
9- * int main()
10- * {
11- * // To avoid using your account, pass anything (apart from null).
12- * // By doing this, you will be limited to public only illustrations.
13- * Client client = Client("MyPHPSessionID");
14- *
15- * Illustration illust = client.fetchIllustration("87445220");
16- * writefln("Downloading %s", illust.title);
17- *
18- * // You can specify the size to download, the default is Size.original.
19- * illust.download(getcwd(), Size.original);
20- *
21- * return 0;
22- * }
23- * ---
24- */
25-module pixivd;
26-
27-import std.array;
28-import std.datetime;
29-import std.datetime.systime;
30-import std.file;
31-import std.format;
32-import std.json;
33-import std.net.curl;
34-import std.path;
35-import std.stdio;
36-import std.string;
37-import std.typecons;
38-import std.zip;
39-
40-import graphicsmagick_c.magick;
41-
42-struct Client {
43- /**
44- * The language you'd like the translations to be in.
45- */
46- string lang = "en";
47-
48- this(string sessionID)
49- {
50- m_sessionID = sessionID;
51- m_pixivConnection = refCounted(HTTP());
52- m_pixivConnection.addRequestHeader("Accept", "application/json");
53- m_pixivConnection.addRequestHeader("Host", "www.pixiv.net");
54- m_pixivConnection.addRequestHeader("Referer", "https://www.pixiv.net/");
55- m_pixivConnection.setUserAgent("Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefix/91.0");
56- m_pixivConnection.setCookie("PHPSESSID=" ~ sessionID);
57- }
58-
59- /**
60- * Fetch the metadata for the specified illustration id.
61- */
62- Illustration fetchIllustration(string id)
63- {
64- auto jsonResponse = appender!string;
65-
66- m_pixivConnection.url = format!"https://www.pixiv.net/ajax/illust/%s?lang=%s"(id, lang);
67- m_pixivConnection.onReceive = (ubyte[] data) {
68- jsonResponse.put(data);
69- return data.length;
70- };
71-
72- m_pixivConnection.perform();
73-
74- JSONValue illustJSON;
75-
76- illustJSON = parseJSON(jsonResponse.data());
77-
78- return Illustration.fromJSON(illustJSON, &this);
79- }
80-
81- Ugoira fetchUgoiraMeta(string id)
82- {
83- auto jsonResponse = appender!string;
84-
85- m_pixivConnection.url = format!"https://www.pixiv.net/ajax/illust/%s/ugoira_meta?lang=%s"(id, lang);
86- m_pixivConnection.onReceive = (ubyte[] data) {
87- jsonResponse.put(data);
88- return data.length;
89- };
90-
91- m_pixivConnection.perform();
92-
93- JSONValue ugoiraJson;
94-
95- ugoiraJson = parseJSON(jsonResponse.data());
96-
97- return Ugoira.fromJson(ugoiraJson, &this);
98- }
99-
100- /**
101- * Fetch new illustrations from followed artists.
102- *
103- * A maximum of 60 illustrations are returned in one response.
104- */
105- long[] fetchIllustrationsFollowing(long page = 1, DailyMode mode = DailyMode.all)
106- {
107- auto app = appender!string();
108-
109- m_pixivConnection.url =
110- format!"https://www.pixiv.net/ajax/follow_latest/illust?p=%d&mode=%s&lang=%s"(page, mode, lang);
111- m_pixivConnection.onReceive = (ubyte[] data) {
112- app.put(data);
113- return data.length;
114- };
115- m_pixivConnection.perform();
116-
117- JSONValue json = parseJSON(app.data());
118-
119- static if (__VERSION__ < 2083L) {
120- if (JSON_TYPE.TRUE == json["error"].type) {
121- throw new Exception(json["message"].str);
122- }
123- } else {
124- if (true == json["error"].boolean) {
125- throw new Exception(json["message"].str);
126- }
127- }
128-
129- JSONValue body_ = json["body"];
130- JSONValue[] jsonIDS = body_["page"]["ids"].array;
131-
132- long[] ids = new long[jsonIDS.length];
133- foreach (i, id; jsonIDS) {
134- ids[i] = id.integer;
135- }
136-
137- return ids;
138- }
139-
140- UserProfile fetchProfileAll(string id)
141- {
142- auto rawResponse = appender!string;
143-
144- m_pixivConnection.url = format!"https://www.pixiv.net/ajax/user/%s/profile/all?lang=%s"(id, lang);
145- m_pixivConnection.onReceive = (ubyte[] data) {
146- rawResponse ~= data;
147- return data.length;
148- };
149-
150- m_pixivConnection.perform();
151-
152- JSONValue jsonResponse = parseJSON(rawResponse.data());
153-
154- UserProfile up = UserProfile.fromJson(jsonResponse);
155-
156- return up;
157- }
158-
159- FullUser fetchUser(string id)
160- {
161- auto rawResponse = appender!string;
162-
163- m_pixivConnection.url = format!"https://www.pixiv.net/ajax/user/%s?full=1&lang=%s"(id, lang);
164- m_pixivConnection.onReceive = (ubyte[] data) {
165- rawResponse ~= data;
166- return data.length;
167- };
168-
169- m_pixivConnection.perform();
170-
171- JSONValue fullUserJson = parseJSON(rawResponse.data());
172-
173- FullUser user = FullUser.fromJSON(fullUserJson);
174-
175- return user;
176- }
177-
178- private string getUserId()
179- {
180- auto tumengResponse = appender!string;
181- m_pixivConnection.url = "https://www.pixiv.net/ajax/linked_service/tumeng?page=1";
182- m_pixivConnection.onReceive = (ubyte[] data) {
183- tumengResponse ~= data;
184- return data.length;
185- };
186-
187- m_pixivConnection.perform();
188-
189- JSONValue json = parseJSON(tumengResponse.data());
190-
191- if (mixin(compatJsonTrue!("json", `["error"]`))) {
192- throw new Exception(json["message"].str);
193- }
194-
195- if ("page" in json["body"] && "user" in json["body"]["page"]) {
196- JSONValue user = json["body"]["page"]["user"];
197-
198- if ("id" in user)
199- return user["id"].str;
200- else
201- return "";
202- }
203-
204- return "";
205- }
206-
207- string[] fetchFollowing(int offset, int limit, long* total, string rest = "show")
208- {
209- auto rawResponse = appender!string;
210-
211- string id = getUserId();
212-
213- if ("" == id)
214- throw new Exception("Couldn't determine user id");
215-
216- m_pixivConnection.url = format!"https://www.pixiv.net/ajax/user/%s/following?offset=%d&limit=%d&rest=%s&lang=%s"
217- (id, offset, limit, rest, this.lang);
218- m_pixivConnection.onReceive = (ubyte[] data) {
219- rawResponse ~= data;
220- return data.length;
221- };
222-
223- m_pixivConnection.perform();
224-
225- JSONValue json = parseJSON(rawResponse.data());
226-
227- if (mixin(compatJsonTrue!("json", `["error"]`))) {
228- throw new Exception(json["message"].str);
229- }
230-
231- string[] ids = new string[limit];
232-
233- foreach(idx, jsonValue; json["body"]["users"].array) {
234- ids[idx] = jsonValue["userId"].str;
235- }
236-
237- if (null !is total)
238- *total = json["body"]["total"].integer;
239-
240- return ids;
241- }
242-
243- @property RefCounted!HTTP pixivConnection()
244- {
245- return m_pixivConnection;
246- }
247-
248- private string m_sessionID;
249- private RefCounted!HTTP m_pixivConnection;
250-}
251-
252-enum ContentType {
253- illustration,
254- manga = 1,
255- ugoira = 2,
256- novel,
257-}
258-
259-/**
260- * This enum represents the possible image sizes that can be downloaded.
261- * Size.original will provide the best quality image.
262- */
263-enum Size {
264- mini = "mini",
265- original = "original",
266- regular = "regular",
267- small = "small",
268- thumb = "thumb",
269-}
270-
271-/**
272- * The mode for downloading daily illustrations.
273- */
274-enum DailyMode : string {
275- all = "all",
276- r18 = "r18",
277- safe = "safe",
278-}
279-
280-struct Illustration {
281- immutable long bookmarkCount;
282- immutable long commentCount;
283- immutable SysTime createDate;
284- immutable string description;
285- immutable long height;
286- immutable string id;
287- immutable string[string] imageURLs;
288- // immutable bool isBookmarked;
289- // immutable bool isMuted;
290- // immutable string[string][] metaPages;
291- immutable long pageCount;
292- immutable long restrict;
293- // immutable long sanityLevel; // is this 'sl'?
294- // ??series??
295- // immutable string[string][] tags;
296- immutable long viewCount;
297- immutable string title;
298- // ??tools??
299- ContentType type;
300- immutable User user;
301- // immutable bool visible;
302- immutable long width;
303- immutable long xRestrict;
304-
305- private Client* m_client;
306-
307- ~this()
308- {
309- m_client = null;
310- }
311-
312- static Illustration fromJSON(JSONValue json, Client* c)
313- {
314- static if (__VERSION__ < 2083L) {
315- if (JSON_TYPE.TRUE == json["error"].type) {
316- throw new Exception(json["message"].str);
317- }
318- } else {
319- if (true == json["error"].boolean) {
320- throw new Exception(json["message"].str);
321- }
322- }
323-
324- scope body_ = json["body"];
325- scope urls = body_["urls"];
326-
327- auto illust = Illustration(
328- body_["bookmarkCount"].integer,
329- body_["commentCount"].integer,
330- SysTime.fromISOExtString(body_["createDate"].str),
331- body_["description"].str,
332- body_["height"].integer,
333- body_["id"].str,
334- [
335- "mini": urls["mini"].str,
336- "original": urls["original"].str,
337- "regular": urls["regular"].str,
338- "small": urls["small"].str,
339- "thumb": urls["thumb"].str,
340- ],
341- body_["pageCount"].integer,
342- body_["restrict"].integer,
343- body_["viewCount"].integer,
344- body_["title"].str,
345- cast(ContentType)body_["illustType"].integer,
346- User(
347- body_["userAccount"].str,
348- body_["userId"].str,
349- body_["userName"].str,
350- ),
351- body_["width"].integer,
352- body_["xRestrict"].integer,
353- );
354-
355- illust.m_client = c;
356-
357- return illust;
358- }
359-
360-
361- /**
362- * Download the illustration to the desired directory.
363- *
364- * If the illustration has multiple pages, a directory will be created and
365- * the images placed inside.
366- *
367- * Params:
368- * directory = The directory to download the illustration to.
369- * size = The size of the image to download.
370- * filename = The filename of a single-page illustration, or the
371- * sub-directory name of a multi-page illustration. Do
372- * $(B not) include an extension. This defaults to the
373- * illustration id.
374- *
375- * Throws:
376- * FileException if the directory does not exist.
377- */
378- void download(string directory, bool overwrite = false, Size size = Size.original, string filename = null)
379- {
380- immutable owd = getcwd();
381- chdir(directory);
382- scope(exit) chdir(owd);
383-
384-
385- if (null is filename)
386- filename = id;
387-
388- if (1 == pageCount) {
389- downloadImage(imageURLs[size], filename, overwrite);
390- } else {
391- downloadPagedImage(size, filename, overwrite);
392- }
393- }
394-
395- /// Download an image which contains multiple pages.
396- private void downloadPagedImage(Size size, string filename, bool overwrite)
397- {
398- mkdirRecurse(filename);
399- scope owd = getcwd();
400- chdir(filename);
401- scope(exit) chdir(owd);
402-
403- auto jsonResponse = appender!string;
404-
405- m_client.pixivConnection.url = format!"https://www.pixiv.net/ajax/illust/%s/pages?lang=%s"(id, m_client.lang);
406- m_client.pixivConnection.onReceive = (ubyte[] data) {
407- jsonResponse.put(data);
408- return data.length;
409- };
410- m_client.pixivConnection.perform();
411-
412- auto pagesJSON = parseJSON(jsonResponse.data());
413- auto pages = pagesJSON["body"].array;
414-
415- foreach (JSONValue page; pages) {
416- string url = page["urls"][size].str;
417- downloadImage(url, baseName(url).stripExtension, overwrite);
418- }
419- }
420-
421- /// Download a single-paged image.
422- private void downloadImage(string url, string filename, bool overwrite)
423- {
424- const fullFilename = filename ~ url.extension;
425-
426- if (false == overwrite && true == exists(fullFilename)) {
427- throw new FileExistsException(fullFilename);
428- }
429-
430- auto imageFile = File(fullFilename, "w+");
431- m_client.pixivConnection.url = url;
432- m_client.pixivConnection.onReceive = (ubyte[] data) {
433- imageFile.rawWrite(data);
434- return data.length;
435- };
436- m_client.pixivConnection.perform();
437- /* Close the file early so setTimes() works */
438- imageFile.close();
439-
440- setTimes(fullFilename, createDate, createDate);
441- }
442-}
443-
444-class FileExistsException : Exception {
445- this(string filename) {
446- super(format!"File already exists: %s"(msg));
447- }
448-}
449-
450-class Ugoira
451-{
452- struct Frame
453- {
454- const string file;
455- const long delay;
456- }
457-
458- string src() const
459- {
460- return m_src;
461- }
462-
463- static Ugoira fromJson(const ref JSONValue js, Client *c)
464- {
465- if (mixin(compatJsonTrue!("js", `["error"]`))) {
466- throw new Exception(js["message"].str);
467- }
468-
469- static if (__VERSION__ < 2083L) {
470- JSON_TYPE arrayType = JSON_TYPE.ARRAY;
471- } else {
472- JSONType arrayType = JSONType.array;
473- }
474-
475- auto ugoira = new Ugoira();
476- ugoira.m_client = c;
477-
478- if ("body" !in js)
479- throw new Exception(`JSON response has no "body".`);
480-
481- if ("src" in js["body"]) {
482- ugoira.m_src = js["body"]["src"].str;
483- }
484-
485- if ("originalSrc" in js["body"]) {
486- ugoira.m_originalSrc = js["body"]["originalSrc"].str;
487- }
488-
489- if ("mime_type" in js["body"]) {
490- ugoira.m_mimeType = js["body"]["mime_type"].str;
491- }
492-
493- if ("frames" in js["body"]) {
494- auto frames = js["body"]["frames"].array;
495- foreach (const ref JSONValue frame; frames) {
496- ugoira.m_frames ~= Frame(frame["file"].str, frame["delay"].integer);
497- }
498- }
499-
500- return ugoira;
501- }
502-
503- //~ void download(string directory, bool original = true) {
504-
505- //~ }
506-
507- void downloadAsGif(string directory, bool original = true, string fileName = null, bool overwrite = false /* ignored */) {
508- string owd = getcwd();
509- chdir(directory);
510- scope(exit) chdir(owd);
511-
512- if (null is fileName) {
513- string id = split(m_src, "/")[$ - 1].split("_")[0];
514- fileName = id ~ ".gif";
515- }
516-
517- string zipFileName;
518-
519- if (original) {
520- m_client.m_pixivConnection.url = m_originalSrc;
521- zipFileName = baseName(m_originalSrc);
522- } else {
523- m_client.m_pixivConnection.url = m_src;
524- zipFileName = baseName(m_src);
525- }
526-
527- File zipFile = File(zipFileName, "w+");
528- scope(exit) remove(zipFileName);
529-
530- m_client.m_pixivConnection.onReceive = (ubyte[] data) {
531- zipFile.rawWrite(data);
532- return data.length;
533- };
534-
535- m_client.m_pixivConnection.perform();
536- zipFile.close();
537-
538- /*
539- * GraphicsMagick Initialization.
540- */
541- InitializeMagick(null);
542- ExceptionInfo exception;
543- Image *image;
544- Image *images;
545-
546- ImageInfo *imageInfo;
547-
548- GetExceptionInfo(&exception);
549- imageInfo = CloneImageInfo(null);
550- images = NewImageList();
551-
552- auto archive = new ZipArchive(read(zipFileName));
553- /* so we can remove the individual files later */
554- string[] amNames = new string[archive.totalEntries];
555- size_t idx = 0;
556-
557- /*
558- * Since the order of members isn't guaranteed, we just extract
559- * all the files.
560- */
561- foreach(name, am; archive.directory) {
562- archive.expand(am);
563- File(name, "w+").rawWrite(am.expandedData);
564- amNames[idx] = name;
565- idx += 1;
566- }
567-
568- foreach (frame; m_frames) {
569- assert(exists(frame.file), "File '" ~ frame.file ~ "' does not exist.");
570-
571- size_t length = frame.file.length > MaxTextExtent ? MaxTextExtent : frame.file.length;
572- imageInfo.filename[0 .. length] = frame.file;
573- imageInfo.filename[length] = '\0';
574-
575- image = ReadImage(imageInfo, &exception);
576- if (UndefinedException != exception.severity) {
577- CatchException(&exception);
578- throw new Exception( cast(string)(fromStringz(exception.description)) );
579- }
580- if (null !is image) {
581- image.delay = cast(uint)frame.delay / 10;
582- image.dispose = PreviousDispose;
583- /* Infinite loop */
584- image.iterations = 0;
585- AppendImageToList(&images, image);
586- }
587- }
588-
589- imageInfo.adjoin = MagickTrue;
590-
591- WriteImages(imageInfo, images, &fileName[0], &exception);
592- if (UndefinedException != exception.severity) {
593- CatchException(&exception);
594- throw new Exception( cast(string)(fromStringz(exception.description)) );
595- }
596-
597- foreach(name; amNames)
598- remove(name);
599-
600- DestroyImageList(images);
601- DestroyImageInfo(imageInfo);
602- DestroyExceptionInfo(&exception);
603- DestroyMagick();
604- }
605-
606-private:
607- string m_mimeType;
608- string m_originalSrc;
609- string m_src;
610- Frame[] m_frames;
611- Client *m_client;
612-}
613-
614-class UserProfile
615-{
616- static UserProfile fromJson(const ref JSONValue js)
617- {
618- if (mixin(compatJsonTrue!("js", `["error"]`))) {
619- throw new Exception(js["message"].str);
620- }
621-
622- auto up = new UserProfile();
623-
624- if ("body" !in js)
625- throw new Exception("JSON response has no \"body\".");
626-
627- /* NOTE: illusts, manga, etc. are arrays if there are none. */
628-
629- static if (__VERSION__ < 2083L) {
630- JSON_TYPE arrayType = JSON_TYPE.ARRAY;
631- } else {
632- JSONType arrayType = JSONType.array;
633- }
634-
635- if ("illusts" in js["body"] && arrayType != js["body"]["illusts"].type) {
636- foreach (k, v; js["body"]["illusts"].objectNoRef) {
637- up.illusts[k] = (v.isNull ? null : v.str);
638- }
639- }
640-
641- if ("manga" in js["body"] && arrayType != js["body"]["manga"].type) {
642- foreach (k, v; js["body"]["manga"].objectNoRef) {
643- up.manga[k] = (v.isNull ? null : v.str);
644- }
645- }
646-
647- return up;
648- }
649-
650- string[string] illusts;
651- string[string] manga;
652-}
653-
654-struct User {
655- /// The user's account name.
656- immutable string account;
657- /// User account ID
658- immutable string id;
659- /// The display name of the account
660- immutable string name;
661-}
662-
663-private template compatJsonTrue(string jsonVarName, string obj)
664-{
665-static if (__VERSION__ < 2083L) {
666- const char[] compatJsonTrue = jsonVarName ~ obj ~ ".type == JSON_TYPE.TRUE";
667-} else {
668- const char[] compatJsonTrue = jsonVarName ~ obj ~ ".boolean == true";
669-}
670-}
671-
672-class FullUser
673-{
674-public:
675-
676- @property string id() const
677- {
678- return m_id;
679- }
680-
681- @property string name() const
682- {
683- return m_name;
684- }
685-
686- @property string image() const
687- {
688- return m_imageURL;
689- }
690-
691- @property string imageBig() const
692- {
693- return m_imageBigURL;
694- }
695-
696- @property bool premium() const
697- {
698- return m_premium;
699- }
700-
701- @property bool isFollowed() const
702- {
703- return m_isFollowed;
704- }
705-
706- static FullUser fromJSON(const ref JSONValue json)
707- {
708- auto user = new FullUser();
709-
710- if (mixin(compatJsonTrue!("json", `["error"]`))) {
711- throw new Exception(json["message"].str);
712- }
713-
714- if ("userId" in json["body"])
715- user.id = json["body"]["userId"].str;
716- else
717- user.id = null;
718-
719- if ("name" in json["body"])
720- user.name = json["body"]["name"].str;
721- else
722- user.name = null;
723-
724- if ("image" in json["body"])
725- user.imageURL = json["body"]["image"].str;
726- else
727- user.imageURL = null;
728-
729- if ("imageBig" in json["body"])
730- user.imageBigURL = json["body"]["imageBig"].str;
731- else
732- user.imageBigURL = null;
733-
734- if ("premium" in json["body"])
735- user.premium = mixin(compatJsonTrue!("json", `["body"]["premium"]`));
736-
737- if ("isFollowed" in json["body"])
738- user.isFollowed = mixin(compatJsonTrue!("json", `["body"]["isFollowed"]`));
739-
740- return user;
741- }
742-
743-package:
744-
745- @property void id(string id_)
746- {
747- m_id = id_;
748- }
749-
750- @property void name(string name_)
751- {
752- m_name = name_;
753- }
754-
755- @property void imageURL(string image)
756- {
757- m_imageURL = image;
758- }
759-
760- @property void imageBigURL(string imageBig)
761- {
762- m_imageBigURL = imageBig;
763- }
764-
765- @property void premium(bool premium_)
766- {
767- m_premium = premium_;
768- }
769-
770- @property void isFollowed(bool followed)
771- {
772- m_isFollowed = followed;
773- }
774-
775-private:
776- string m_id;
777- string m_name;
778- string m_imageURL;
779- string m_imageBigURL;
780- bool m_premium;
781- bool m_isFollowed;
782- bool m_isMyPixiv;
783- bool m_isBlocking;
784- /* background */
785- /* sketchLiveId */
786- long m_partial;
787- bool m_acceptRequest;
788- /* sketchLives[] */
789- long m_following;
790- bool m_followedBack;
791- string m_comment;
792- string m_commentHTML;
793- string m_webpage;
794- /* social */
795- /* region */
796- /* birthDay */
797- /* gender */
798- /* job */
799- /* workspace */
800- bool m_official;
801- /* group */
802-}
--- /dev/null
+++ b/source/pixivd/client.d
@@ -0,0 +1,995 @@
1+module pixivd.client;
2+
3+import core.sync.mutex : Mutex;
4+
5+import std.array : appender;
6+import std.format : format;
7+import std.json;
8+import std.net.curl : HTTP;
9+
10+import graphicsmagick_c;
11+import graphicsmagick_c.magick.api;
12+
13+import pixivd.enums;
14+import pixivd.mixins;
15+import pixivd.types;
16+
17+public enum PixivDVersion = 0.7;
18+public enum PixivDVersionString = "0.7";
19+
20+/**
21+ *
22+ * The main client for performing pixiv API requests.
23+ *
24+ * ```
25+ * // Create without setting the PHPSESSID
26+ * auto client = new Client();
27+ * // Set the PHPSESSID
28+ * client.phpsessid = "COOKIE_VALUE_HERE";
29+ * ```
30+ */
31+class Client
32+{
33+private:
34+
35+ string m_phpsessid;
36+ HTTP m_client;
37+
38+ bool m_isGMInitialized = false;
39+
40+public:
41+
42+ @property string phpsessid() const
43+ {
44+ return m_phpsessid;
45+ }
46+
47+ @property void phpsessid(string sessionID)
48+ {
49+ m_phpsessid = sessionID;
50+ m_client.setCookie("PHPSESSID=" ~ m_phpsessid);
51+ }
52+
53+ /**
54+ * Create a new instance of Client, providing a PHPSESSID to begin
55+ * with.
56+ *
57+ * Params:
58+ * - phpsessid The PHPSESSID Cookie from a Web Browser.
59+ */
60+ this(string phpsessid)
61+ {
62+ m_phpsessid = phpsessid;
63+ m_client = HTTP();
64+ m_client.addRequestHeader("Accept", "application/json, */*");
65+ m_client.addRequestHeader("Host", "www.pixiv.net");
66+ m_client.addRequestHeader("Referer", "https://www.pixiv.net/");
67+ m_client.setUserAgent(
68+ "Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Firefix/91.0");
69+ if ("" != m_phpsessid)
70+ {
71+ m_client.setCookie("PHPSESSID=" ~ m_phpsessid);
72+ }
73+ }
74+
75+ this()
76+ {
77+ this("");
78+ }
79+
80+ ~this()
81+ {
82+ m_client.shutdown();
83+ if (true == m_isGMInitialized) {
84+ DestroyMagick();
85+ }
86+ }
87+
88+ /**
89+ * Fetch an illustration
90+ *
91+ * Throws:
92+ * - ConvException if we failed to parse the `id` as a string.
93+ * - PixivExcetpion if something happened when parsing the response.
94+ */
95+ Illustration fetchIllustration(string id)
96+ {
97+ auto response = appender!string;
98+
99+ m_client.url = format!"https://www.pixiv.net/ajax/illust/%s"(id);
100+ m_client.onReceive = (ubyte[] data) {
101+ response.put(data);
102+ return data.length;
103+ };
104+
105+ m_client.perform();
106+
107+ JSONValue json = parseJSON(response.data());
108+
109+ return Illustration.fromJSON(json);
110+ }
111+
112+ /**
113+ * Fetch an illustration.
114+ *
115+ * Params:
116+ * thumb = A Thumbnail for the image you want to fetch.
117+ * Returns: A complete Image instance for the provided Thumbnail.
118+ */
119+ Illustration fetchIllustration(Thumbnail thumb)
120+ {
121+ auto response = appender!string;
122+
123+ m_client.url = format!"https://www.pixiv.net/ajax/illust/%s"(thumb.id);
124+ m_client.onReceive = (ubyte[] data) {
125+ response.put(data);
126+ return data.length;
127+ };
128+
129+ m_client.perform();
130+
131+ JSONValue json = parseJSON(response.data());
132+
133+ return Illustration.fromJSON(json);
134+ }
135+
136+ ///
137+ unittest
138+ {
139+ import core.thread : Thread;
140+ import core.time : seconds;
141+
142+ import std.net.curl : CurlTimeoutException;
143+ import std.stdio : writefln;
144+
145+ auto client = new Client();
146+
147+ try
148+ {
149+ Illustration illust = client.fetchIllustration("95917058");
150+ assert("95917058" == illust.id);
151+ assert("user_hccs8584" == illust.userAccount);
152+
153+ writefln("%s -- Sleeping for 3 seconds", __PRETTY_FUNCTION__);
154+ Thread.sleep(3.seconds); // Try not to abuse the server.
155+ }
156+ catch (CurlTimeoutException cte)
157+ {
158+ // It's possible that there is no internet connection
159+ // or that Pixiv is down, so we won't error.
160+ writefln("CurlTimeoutException: %s", cte.msg);
161+ }
162+ }
163+
164+ /// ditto
165+ Illustration fetchIllustration(size_t id)
166+ {
167+ import std.conv : to;
168+
169+ return fetchIllustration(to!string(id));
170+ }
171+
172+ ///
173+ unittest
174+ {
175+ import core.thread : Thread;
176+ import core.time : seconds;
177+
178+ import std.net.curl : CurlTimeoutException;
179+ import std.stdio : writefln;
180+
181+ auto client = new Client();
182+
183+ try
184+ {
185+ Illustration illust = client.fetchIllustration(95917058);
186+ assert("95917058" == illust.id);
187+ assert("user_hccs8584" == illust.userAccount);
188+
189+ writefln("%s -- Sleeping for 3 seconds", __PRETTY_FUNCTION__);
190+ Thread.sleep(3.seconds); // Try not to abuse the server.
191+ }
192+ catch (CurlTimeoutException cte)
193+ {
194+ // It's possible that there is no internet connection
195+ // or that Pixiv is down, so we won't error.
196+ writefln("CurlTimeoutException: %s", cte.msg);
197+ }
198+ }
199+
200+ Thumbnail[] fetchIllustrationsFollowing(long page = 1, DailyMode mode = DailyMode.all, string lang = "en")
201+ {
202+ auto res = appender!string;
203+
204+ m_client.url = format!"https://www.pixiv.net/ajax/follow_latest/illust?p=%d&mode=%s&lang=%s"(page, mode, lang);
205+ m_client.onReceive = (ubyte[] data) { res.put(data); return data.length; };
206+
207+ m_client.perform();
208+
209+ JSONValue json = parseJSON(res.data());
210+
211+ static if (__VERSION__ < 2083L)
212+ {
213+ if (JSON_TYPE.TRUE == json["error"].type)
214+ {
215+ throw new PixivJSONException(json["message"].str);
216+ }
217+ }
218+ else
219+ {
220+ if (true == json["error"].boolean)
221+ {
222+ throw new PixivJSONException(json["message"].str);
223+ }
224+ }
225+
226+ if ("body" !in json)
227+ {
228+ throw new PixivJSONException("No \"body\" in returned JSON.");
229+ }
230+
231+ auto body_ = json["body"];
232+
233+ if ("thumbnails" !in body_)
234+ {
235+ throw new PixivJSONException("No \"thumbnails\" in fetchIllustrationsFollowing");
236+ }
237+
238+ Thumbnail[] thumbnails;
239+ auto jsonThumbs = body_["thumbnails"]["illust"].array;
240+
241+ foreach (ref thumb; jsonThumbs)
242+ {
243+ thumbnails ~= Thumbnail.fromJSON(thumb);
244+ }
245+
246+ return thumbnails;
247+ }
248+
249+ ///
250+ unittest
251+ {
252+ import std.net.curl : CurlTimeoutException;
253+ import std.process : environment;
254+ import std.stdio : writefln;
255+
256+ auto phpsessid = environment.get("PIXIV_PHPSESSID");
257+ if (null is phpsessid)
258+ {
259+ // Won't test as there is no PHPSESSID.
260+ return;
261+ }
262+
263+ auto client = new Client(phpsessid);
264+
265+ try
266+ {
267+ import core.thread : Thread;
268+ import core.time : seconds;
269+
270+ Thumbnail[] thumbs = client.fetchIllustrationsFollowing();
271+ assert(thumbs.length > 0);
272+
273+ writefln("%s -- Sleeping for 3 seconds.", __PRETTY_FUNCTION__);
274+ Thread.sleep(3.seconds);
275+ }
276+ catch (CurlTimeoutException cte)
277+ {
278+ // It's possible that there is no internet connection
279+ // or that Pixiv is down, so we won't error.
280+ writefln("CurlTimeoutException: %s", cte.msg);
281+ }
282+ }
283+
284+ /**
285+ * Fetch a list of Users that the logged in user follows.
286+ *
287+ * Params:
288+ * offset = [in] The number of accounts to offset the retrieval by
289+ * limit = [in] Limit the number of accounts returned
290+ * show = [in] Whether to retrieve public ("show"), or private ("hide") followings.
291+ * Returns: An array of `User`.
292+ */
293+ User[] fetchFollowing(int offset, int limit = 24, string rest = "show")
294+ {
295+ long numAccounts;
296+ return fetchFollowing(offset, numAccounts, limit, rest);
297+ }
298+
299+ /**
300+ * Fetch a list of Users that the logged in user follows.
301+ *
302+ * An optional `totalNumberOfAccounts` parameter allows you to store the total number
303+ * of accounts the user follows (the value differs depending on `rest`).
304+ *
305+ * Params:
306+ * offset = The number of accounts to offset the retrieval by.
307+ * totalNumberOfAccounts = [out] The total number of accounts the user follows (depends on `rest`).
308+ * limit = Limit the number of accounts returned (default 24).
309+ * show = [in] Whether to retrieve public ("show"), or private ("hide") followings.
310+ */
311+ User[] fetchFollowing(in int offset, out long totalNumberOfAccounts, in int limit = 24, in string rest = "show")
312+ {
313+ import std.string : split;
314+
315+ auto response = appender!string;
316+
317+ if ("" == m_phpsessid)
318+ {
319+ throw new Exception("PHPSESSID not set. Please use `.phpsessid = `");
320+ }
321+
322+ string id = m_phpsessid.split('_')[0];
323+ if ("" == id)
324+ {
325+ throw new Exception("Could not determine user id");
326+ }
327+
328+ m_client.url = "https://www.pixiv.net/ajax/user/%s/following?offset=%d&limit=%d&rest=%s&lang=en".format(id,
329+ offset, limit, rest);
330+ m_client.onReceive = (ubyte[] data) {
331+ response.put(data);
332+ return data.length;
333+ };
334+ m_client.perform();
335+
336+ auto json = parseJSON(response[]);
337+ if (mixin(mixCheckJsonError!("json")))
338+ {
339+ throw new PixivJSONException(json["message"].str);
340+ }
341+
342+ if ("body" !in json)
343+ {
344+ throw new PixivJSONException("No \"body\" in returned JSON.");
345+ }
346+
347+ User[] users;
348+ totalNumberOfAccounts = json["body"]["total"].integer;
349+ auto jsonUsers = json["body"]["users"].array;
350+ foreach (ref jsonUser; jsonUsers)
351+ {
352+ users ~= User.fromJSON(jsonUser);
353+ }
354+
355+ return users;
356+ }
357+
358+ ///
359+ unittest
360+ {
361+ import std.net.curl : CurlTimeoutException;
362+ import std.process : environment;
363+ import std.stdio : writefln;
364+
365+ auto sessid = environment.get("PIXIV_PHPSESSID");
366+ if (null is sessid)
367+ {
368+ // test requires authorisation, so will skip.
369+ return;
370+ }
371+
372+ auto client = new Client(sessid);
373+ try
374+ {
375+ User[] users = client.fetchFollowing(0);
376+ assert(0 == users.length, "No users returned (assumes you follow anyone)");
377+
378+ foreach (user; users)
379+ {
380+ assert("" != user.userId);
381+ assert("" != user.userName);
382+ }
383+ }
384+ catch (CurlTimeoutException cte)
385+ {
386+ // Possible that there is no internet connection
387+ // or that pixiv is down.
388+ writefln("CurlTimeoutException: %s", cte.msg);
389+ }
390+ }
391+
392+ FullUser fetchUser(User user)
393+ {
394+ return fetchUser(user.userId);
395+ }
396+
397+ FullUser fetchUser(string id)
398+ {
399+ auto response = appender!string;
400+
401+ m_client.url = "https://www.pixiv.net/ajax/user/%s?full=1&lang=en".format(id);
402+ m_client.onReceive = (ubyte[] data) {
403+ response.put(data);
404+ return data.length;
405+ };
406+
407+ m_client.perform();
408+
409+ auto json = parseJSON(response.data());
410+ if (mixin(mixCheckJsonError!("json")))
411+ {
412+ throw new PixivJSONException(json["message"].str);
413+ }
414+
415+ if ("body" !in json)
416+ {
417+ throw new PixivJSONException("JSON response contains no 'body'.");
418+ }
419+
420+ return FullUser.fromJSON(json["body"]);
421+ }
422+
423+ ///
424+ unittest
425+ {
426+ import std.net.curl : CurlTimeoutException;
427+
428+ auto client = new Client();
429+ FullUser user;
430+
431+ try
432+ {
433+ user = client.fetchUser("4938312");
434+ }
435+ catch (CurlTimeoutException cte)
436+ {
437+ import std.stdio : stderr;
438+
439+ stderr.writefln("%s -- Failed to connect to server: ", __PRETTY_FUNCTION__, cte.msg);
440+ return; // exit test early.
441+ }
442+ assert("4938312" == user.userId, "user.userId != 4938312");
443+ assert(false == user.following, "user.following != false");
444+ assert(false == user.followed, "user.followed != false");
445+ }
446+
447+ UserBrief fetchUserAll(string id)
448+ {
449+ auto response = appender!string;
450+
451+ m_client.url = "https://www.pixiv.net/ajax/user/%s/profile/all".format(id);
452+ m_client.onReceive = (ubyte[] data) {
453+ response.put(data);
454+ return data.length;
455+ };
456+
457+ m_client.perform();
458+
459+ auto json = parseJSON(response.data());
460+ if (mixin(mixCheckJsonError!("json")))
461+ {
462+ throw new PixivJSONException(json["message"].str);
463+ }
464+
465+ if ("body" !in json)
466+ {
467+ throw new PixivJSONException("JSON response contains no 'body'.");
468+ }
469+
470+ return UserBrief.fromJSON(json["body"]);
471+ }
472+
473+ /**
474+ * Download an Illustration (incl. manga, novels, ugoira).
475+ *
476+ * Params:
477+ * illust = Illustration to download
478+ * directory = Directory where to save the Illustration
479+ * overwrite = Overwrite existing file(s)?
480+ */
481+ void downloadIllust(Illustration illust, string directory, bool overwrite = false)
482+ {
483+ switch (illust.type)
484+ {
485+ case ContentType.illustration:
486+ _downloadIllustration(illust, directory, overwrite);
487+ break;
488+ case ContentType.manga:
489+ _downloadManga(illust, directory, overwrite);
490+ break;
491+ case ContentType.novel:
492+ _downloadNovel(illust, directory, overwrite);
493+ break;
494+ case ContentType.ugoira:
495+ _downloadUgoira(illust, directory, overwrite);
496+ break;
497+ default:
498+ assert(0, "Unsupported Content Type for illustration " ~ illust.id);
499+ }
500+ }
501+
502+ ///
503+ unittest
504+ {
505+ import std.datetime.systime : SysTime;
506+ import std.file : exists, getcwd, getTimes, remove;
507+ import std.path : extension;
508+ import std.stdio : File;
509+
510+ auto client = new Client();
511+
512+ try
513+ {
514+ // Download a single illustration
515+ Illustration illust = client.fetchIllustration("82631500");
516+ assert("フフフ" == illust.title, "Incorrect illustration title");
517+ assert(1 == illust.pages, "Incorrect number of pages");
518+ assert("2020-06-28T15:13:22+00:00" == illust.createDate, "Incorrect createDate");
519+
520+ const fileName = illust.id ~ illust.urls["original"].extension;
521+
522+ client.downloadIllust(illust, getcwd(), true);
523+
524+ assert(true == exists(fileName), "true != exists(fileName)");
525+ File imageFile = File(fileName, "r");
526+ scope (exit)
527+ {
528+ remove(fileName);
529+ }
530+
531+ SysTime createDate = SysTime.fromISOExtString(illust.createDate);
532+ SysTime oAccessTime;
533+ SysTime oModificationTime;
534+ getTimes(fileName, oAccessTime, oModificationTime);
535+
536+ assert(oAccessTime == createDate, "oAccessTime != createDate");
537+ assert(oModificationTime == createDate, "oModificationTIme != createDate");
538+ assert(imageFile.size > 0, "illust file size is not 0");
539+ }
540+ catch (Exception e)
541+ {
542+ assert(false, e.msg);
543+ }
544+
545+ import std.stdio : stderr;
546+ import core.thread : Thread;
547+ import core.time : seconds;
548+
549+ stderr.writefln("%s -- Sleeping for 3 seconds", __PRETTY_FUNCTION__);
550+ Thread.sleep(3.seconds);
551+ }
552+
553+ ///
554+ unittest
555+ {
556+ import std.file : chdir, exists, getcwd, rmdirRecurse;
557+ import std.format : format;
558+ import std.net.curl : CurlTimeoutException;
559+ import std.path : extension;
560+
561+ auto client = new Client();
562+
563+ try
564+ {
565+ Illustration illust = client.fetchIllustration("81573844");
566+
567+ assert("過去絵ミクさん" == illust.title, "Incorrect illustration title");
568+ assert(2 == illust.pages, "Incorrect number of pages");
569+
570+ client.downloadIllust(illust, getcwd(), true);
571+ const ext = extension(illust.urls["original"]);
572+
573+ chdir(illust.id);
574+
575+ foreach (i; 0 .. illust.pages)
576+ {
577+ const filename = format!"%s_p%d%s"(illust.id, i, ext);
578+ assert(exists(filename), "Multi-paged image not downloading all pages");
579+ }
580+
581+ chdir("..");
582+
583+ rmdirRecurse(illust.id);
584+ }
585+ catch (CurlTimeoutException cte)
586+ {
587+ // PASS
588+ import std.stdio : stderr;
589+
590+ stderr.writefln("TIMEOUT: %s", cte.msg);
591+ }
592+
593+ import std.stdio : stderr;
594+ import core.thread : Thread;
595+ import core.time : seconds;
596+
597+ stderr.writefln("%s -- Sleeping for 3 seconds", __PRETTY_FUNCTION__);
598+ Thread.sleep(3.seconds);
599+ }
600+
601+ /**
602+ * Fetch metadata for an ugoira (moving illustration).
603+ *
604+ * Params:
605+ * illust = The illustration (which is an Ugoira) to fetch the metadata for
606+ * Returns: An Ugoira instance containing the source URL and Frame durations for the ugoira.
607+ */
608+ Ugoira fetchUgoiraMeta(Illustration illust)
609+ {
610+ return fetchUgoiraMeta(illust.id);
611+ }
612+
613+ /**
614+ * Fetch metadata for an ugoira (moving illustration).
615+ *
616+ * Params:
617+ * id = The illustration id to fetch the metadata for.
618+ * Returns: An Ugoira instance containing the source URL and Frame durations for the ugoira.
619+ */
620+ Ugoira fetchUgoiraMeta(string id)
621+ {
622+ auto response = appender!string;
623+
624+ m_client.url = format!"https://www.pixiv.net/ajax/illust/%s/ugoira_meta"(id);
625+ m_client.onReceive = (ubyte[] data) {
626+ response ~= data;
627+ return data.length;
628+ };
629+ m_client.perform();
630+
631+ JSONValue json = parseJSON(response.data());
632+
633+ if ("body" !in json)
634+ {
635+ throw new PixivJSONException("No \"body\" in JSON response.");
636+ }
637+ return Ugoira.fromJSON(json);
638+ }
639+
640+ ///
641+ unittest
642+ {
643+ auto client = new Client();
644+
645+ Ugoira ugoira = client.fetchUgoiraMeta("103331804");
646+
647+ assert("image/jpeg" == ugoira.getMimeType());
648+ assert("https://i.pximg.net/img-zip-ugoira/img/2022/12/04/16/13/51/103331804_ugoira600x600.zip" ==
649+ ugoira.getSource());
650+
651+ import std.stdio : stderr;
652+ import core.thread : Thread;
653+ import core.time : seconds;
654+
655+ stderr.writefln("%s -- Sleeping for 3 seconds", __PRETTY_FUNCTION__);
656+ Thread.sleep(3.seconds);
657+ }
658+
659+private:
660+
661+ void _downloadIllustration(Illustration illust, string directory, bool overwrite)
662+ {
663+ import std.file : getcwd, chdir, mkdirRecurse, exists;
664+
665+ if (false == exists(directory))
666+ {
667+ mkdirRecurse(directory);
668+ }
669+
670+ const originalDir = getcwd();
671+ chdir(directory);
672+
673+ if (1 == illust.pages)
674+ {
675+ _downloadSingleIllust(illust, overwrite);
676+ }
677+ else
678+ {
679+ _downloadPagedIllust(illust, overwrite);
680+ }
681+
682+ chdir(originalDir);
683+ }
684+
685+ void _downloadSingleIllust(Illustration illust, bool overwrite)
686+ {
687+ import std.datetime.systime;
688+
689+ import std.file : FileException, exists, setTimes;
690+ import std.path : extension;
691+ import std.stdio : File;
692+
693+ const baseFileName = illust.id ~ illust.urls["original"].extension;
694+
695+ if (true == exists(baseFileName) && false == overwrite)
696+ {
697+ throw new FileException(baseFileName, "File already exists");
698+ }
699+
700+ File imageFile = File(baseFileName, "w+");
701+
702+ m_client.url = illust.urls["original"];
703+ m_client.onReceive = (ubyte[] data) {
704+ imageFile.rawWrite(data);
705+ return data.length;
706+ };
707+
708+ m_client.perform();
709+ imageFile.close();
710+
711+ SysTime createDate = SysTime.fromISOExtString(illust.createDate);
712+
713+ setTimes(baseFileName, createDate, createDate);
714+ }
715+
716+ void _downloadPagedIllust(Illustration illust, bool overwrite)
717+ {
718+ import std.array : appender;
719+ import std.file : chdir, exists, getcwd, mkdir;
720+ import std.stdio : File;
721+
722+ if (false == exists(illust.id))
723+ {
724+ mkdir(illust.id);
725+ }
726+ const originalDir = getcwd();
727+ chdir(illust.id);
728+ scope (exit)
729+ chdir(originalDir);
730+
731+ auto response = appender!string;
732+
733+ m_client.url = "https://www.pixiv.net/ajax/illust/%s/pages".format(illust.id);
734+ m_client.onReceive = (ubyte[] data) {
735+ response.put(data);
736+ return data.length;
737+ };
738+ m_client.perform();
739+
740+ JSONValue json = parseJSON(response.data());
741+ if (mixin(mixCheckJsonError!("json")))
742+ {
743+ throw new PixivJSONException(json["msg"].str);
744+ }
745+
746+ if ("body" !in json)
747+ {
748+ throw new PixivJSONException("JSON response contains no 'body'.");
749+ }
750+
751+ foreach (jsonobj; json["body"].array)
752+ {
753+ import std.path : baseName, stripExtension;
754+
755+ const url = jsonobj["urls"]["original"].str;
756+ File img = File(baseName(url), "w+");
757+
758+ m_client.url = url;
759+ m_client.onReceive = (ubyte[] data) {
760+ img.rawWrite(data);
761+ return data.length;
762+ };
763+
764+ m_client.perform();
765+ img.close();
766+ }
767+ }
768+
769+ void _downloadManga(Illustration illust, string directory, bool overwrite)
770+ {
771+ import std.array : appender;
772+ import std.datetime : SysTime;
773+ import std.file : FileException, chdir, getcwd, exists, mkdirRecurse, setTimes;
774+ import std.stdio : File;
775+ import std.path : baseName, buildPath;
776+
777+ if (false == exists(directory)) {
778+ mkdirRecurse(directory);
779+ }
780+
781+ immutable origDir = getcwd();
782+ chdir(directory);
783+ scope(exit) chdir(origDir);
784+
785+ if (false == exists(illust.id)) {
786+ mkdirRecurse(illust.id);
787+ }
788+ chdir(illust.id);
789+
790+ auto response = appender!string;
791+ m_client.url = "https://www.pixiv.net/ajax/illust/%s/pages".format(illust.id);
792+ m_client.onReceive = (ubyte[] data) {
793+ response.put(data);
794+ return data.length;
795+ };
796+
797+ m_client.perform();
798+
799+ auto json = parseJSON(response.data());
800+ if (mixin(mixCheckJsonError!("json"))){
801+ throw new PixivJSONException(json["message"].str);
802+ }
803+
804+ auto bodyArr = json["body"].array;
805+
806+ foreach(obj; bodyArr) {
807+ auto filename = baseName(obj["urls"]["original"].str);
808+ if (true == exists(filename) && false == overwrite) {
809+ throw new FileException(filename, "File already exists");
810+ }
811+ File outFile = File(filename, "w+");
812+ m_client.url = obj["urls"]["original"].str;
813+ m_client.onReceive = (ubyte[] data) {
814+ outFile.rawWrite(data);
815+ return data.length;
816+ };
817+ m_client.perform();
818+ outFile.close();
819+
820+ SysTime createDate = SysTime.fromISOExtString(illust.createDate);
821+
822+ setTimes(filename, createDate, createDate);
823+ }
824+ }
825+
826+ void _downloadNovel(Illustration illust, string directory, bool overwrite)
827+ {
828+ throw new Exception("Unsupported media type: Novel");
829+ }
830+
831+ void _downloadUgoira(Illustration illust, string directory, bool overwrite)
832+ {
833+ import core.stdc.string : strncpy;
834+
835+ import std.file : chdir, getcwd, mkdirRecurse, read, remove, rmdirRecurse, tempDir;
836+ import std.format : format;
837+ import std.path : baseName, buildPath;
838+ import std.stdio : File;
839+ import std.string : fromStringz;
840+ import std.zip : ZipArchive, ArchiveMember;
841+
842+ // We want to download a GIF.
843+ // To do this we:
844+ // 1. Download the zip file for the Ugoira
845+ // 2. Extract the zip file
846+ // 3. Create an ImageList in GraphicsMagick
847+ // 4. Append all the individual frames with the specific delay.
848+ // 5. Save the ImageList as a GIF file.
849+
850+ // NOTE: There is no need to remove the individual files as all
851+ // work takes place in a directory which is deleted at the end.
852+
853+ string gifFileName = illust.id ~ ".gif";
854+
855+ string originalDir = getcwd();
856+ chdir(tempDir());
857+
858+ mkdirRecurse(illust.id);
859+ chdir(illust.id);
860+ scope (exit)
861+ {
862+ // We will likely be in the |directory| provided.
863+ chdir(tempDir());
864+ rmdirRecurse(illust.id);
865+
866+ chdir(originalDir);
867+ }
868+
869+ Ugoira ugoira = fetchUgoiraMeta(illust);
870+
871+ string zipFileName = baseName(ugoira.getOriginalSource());
872+
873+ auto zipFile = File(zipFileName, "w+");
874+
875+ m_client.url = ugoira.getOriginalSource();
876+ m_client.onReceive = (ubyte[] data) {
877+ zipFile.rawWrite(data);
878+ return data.length;
879+ };
880+
881+ m_client.perform();
882+ zipFile.close();
883+
884+ auto zip = new ZipArchive(read(zipFileName));
885+
886+ foreach (name, ArchiveMember am; zip.directory())
887+ {
888+ zip.expand(am);
889+ File(name, "w+").rawWrite(am.expandedData());
890+ }
891+
892+ if (false == m_isGMInitialized)
893+ {
894+
895+ version (GMagick_Static)
896+ {
897+ // no-op
898+ }
899+ else
900+ {
901+ void* libgm;
902+ GMSupport support = loadGraphicsMagick(libgm);
903+
904+ if (GMSupport.noLibrary == support)
905+ {
906+ throw new PixivException("No GraphicsMagick library found.");
907+ }
908+ }
909+
910+ InitializeMagick(null);
911+ m_isGMInitialized = true;
912+ }
913+
914+ Image* currentImage;
915+ Image* imageList;
916+
917+ ImageInfo* imageInfo;
918+ ExceptionInfo exception;
919+
920+ GetExceptionInfo(&exception);
921+ imageInfo = CloneImageInfo(null);
922+ imageList = NewImageList();
923+
924+ foreach (frame; ugoira.getFrames())
925+ {
926+ string filename = buildPath(getcwd(), frame.file);
927+ size_t length = filename.length;
928+ strncpy(imageInfo.filename.ptr, filename.ptr, length);
929+
930+ currentImage = ReadImage(imageInfo, &exception);
931+
932+ if (UndefinedException != exception.severity)
933+ {
934+ CatchException(&exception);
935+ string msg = format!"Error reading GIF Image: %s -- %s"(
936+ fromStringz(exception.reason),
937+ fromStringz(exception.description));
938+ throw new PixivException(msg);
939+ }
940+
941+ if (null !is currentImage)
942+ {
943+ currentImage.delay = frame.delay / 10;
944+ currentImage.dispose = PreviousDispose;
945+ currentImage.iterations = 0;
946+ AppendImageToList(&imageList, currentImage);
947+ }
948+ }
949+
950+ if (null !is imageList)
951+ {
952+ imageInfo.adjoin = MagickTrue;
953+
954+ chdir(directory);
955+ WriteImages(imageInfo, imageList, gifFileName.ptr, &exception);
956+
957+ if (UndefinedException != exception.severity)
958+ {
959+ CatchException(&exception);
960+ string msg = format!"Error reading GIF Image: %s -- %s"(
961+ fromStringz(exception.reason),
962+ fromStringz(exception.description));
963+ throw new PixivException(msg);
964+ }
965+ DestroyImageList(imageList);
966+ }
967+
968+ if (null !is imageInfo)
969+ {
970+ DestroyImageInfo(imageInfo);
971+ }
972+
973+ DestroyExceptionInfo(&exception);
974+ }
975+
976+ unittest
977+ {
978+ import std.file : exists, getcwd;
979+
980+ auto client = new Client();
981+
982+ Illustration illust = client.fetchIllustration("44360221");
983+ assert(ContentType.ugoira == illust.type, "44360221 is not an Ugoira");
984+
985+ client.downloadIllust(illust, getcwd(), true);
986+ assert(true == exists("44360221.gif"), "44360221.gif does not exist after download.");
987+
988+ import std.stdio : stderr;
989+ import core.thread : Thread;
990+ import core.time : seconds;
991+
992+ stderr.writefln("%s -- Sleeping for 3 seconds", __PRETTY_FUNCTION__);
993+ Thread.sleep(3.seconds);
994+ }
995+}
--- /dev/null
+++ b/source/pixivd/enums.d
@@ -0,0 +1,18 @@
1+module pixivd.enums;
2+
3+enum ContentType
4+{
5+ illustration = 0,
6+ manga = 1,
7+ ugoira = 2,
8+ novel
9+}
10+
11+/**
12+ * The mode for downloading daily illustrations.
13+ */
14+enum DailyMode : string {
15+ all = "all",
16+ r18 = "r18",
17+ safe = "safe",
18+}
--- /dev/null
+++ b/source/pixivd/mixins.d
@@ -0,0 +1,24 @@
1+module pixivd.mixins;
2+
3+package(pixivd) template mixCheckJsonError(string jsonVarName)
4+{
5+ static if (__VERSION__ < 2083L)
6+ {
7+ const char[] mixCheckJsonError = jsonVarName ~ "[\"error\"].type == JSON_TYPE.TRUE";
8+ }
9+ else
10+ {
11+ const char[] mixCheckJsonError = jsonVarName ~ "[\"error\"].boolean == true";
12+ }
13+}
14+
15+package(pixivd) template mixGetJsonBoolean(string jsonVarName, string key) {
16+ static if (__VERSION__ < 2083L)
17+ {
18+ const char[] mixGetJsonBoolean = jsonVarName ~ "[\"" ~ key ~ "\"].type == JSON_TYPE.TRUE";
19+ }
20+ else
21+ {
22+ const char[] mixGetJsonBoolean = jsonVarName ~ "[\"" ~ key ~ "\"].boolean()";
23+ }
24+}
--- /dev/null
+++ b/source/pixivd/package.d
@@ -0,0 +1,7 @@
1+module pixivd;
2+
3+public
4+{
5+ import pixivd.client;
6+ import pixivd.enums;
7+}
--- /dev/null
+++ b/source/pixivd/types/illustration.d
@@ -0,0 +1,95 @@
1+module pixivd.types.illustration;
2+
3+import std.json;
4+
5+import pixivd.types.pixiv_exception;
6+import pixivd.enums : ContentType;
7+import pixivd.mixins;
8+
9+class Illustration
10+{
11+ string id;
12+ string title;
13+ string description;
14+ ContentType type;
15+ string createDate;
16+ string uploadDate;
17+ size_t restrict;
18+ size_t xRestrict;
19+ string[string] urls;
20+ // tags
21+ string alt;
22+ string userId;
23+ string userName;
24+ string userAccount;
25+ // userIllusts
26+ bool likeData;
27+ size_t width;
28+ size_t height;
29+ size_t pages;
30+ size_t bookmarks;
31+ size_t likes;
32+ size_t comments;
33+ size_t responses;
34+ size_t views;
35+
36+ /**
37+ * Create an Illustration from a JSONValue.
38+ */
39+ static Illustration fromJSON(in ref JSONValue json)
40+ {
41+ if (mixin(mixCheckJsonError!("json"))) {
42+ throw new PixivJSONException(json["message"].str);
43+ }
44+
45+ if ("body" !in json) {
46+ throw new PixivJSONException("No \"body\" in returned JSON.");
47+ }
48+
49+ JSONValue body_ = json["body"];
50+ auto illust = new Illustration();
51+
52+ // Set the image properties.
53+ // Nested in `try` because any of the .str/.integer/.etc methods
54+ // can throw if the JSON value doesn't match the type.
55+ try {
56+ illust.id = body_["id"].str;
57+ illust.title = body_["title"].str;
58+ illust.description = body_["description"].str;
59+ illust.type = cast(ContentType)body_["illustType"].integer;
60+ illust.createDate = body_["createDate"].str;
61+ illust.uploadDate = body_["uploadDate"].str;
62+ illust.restrict = body_["restrict"].integer;
63+ illust.xRestrict = body_["xRestrict"].integer;
64+
65+ auto urls = body_["urls"];
66+
67+ illust.urls["mini"] = urls["mini"].str;
68+ illust.urls["thumb"] = urls["thumb"].str;
69+ illust.urls["small"] = urls["small"].str;
70+ illust.urls["regular"] = urls["regular"].str;
71+ illust.urls["original"] = urls["original"].str;
72+
73+ illust.alt = body_["alt"].str;
74+ illust.userId = body_["userId"].str;
75+ illust.userName = body_["userName"].str;
76+ illust.userAccount = body_["userAccount"].str;
77+
78+ // userIllusts
79+
80+ illust.likeData = mixin(mixGetJsonBoolean!("body_", "likeData"));
81+ illust.width = body_["width"].integer;
82+ illust.height = body_["height"].integer;
83+ illust.pages = body_["pageCount"].integer;
84+ illust.bookmarks = body_["bookmarkCount"].integer;
85+ illust.likes = body_["likeCount"].integer;
86+ illust.comments = body_["commentCount"].integer;
87+ illust.responses = body_["responseCount"].integer;
88+ illust.views = body_["viewCount"].integer;
89+
90+ return illust;
91+ } catch (JSONException e) {
92+ throw new PixivJSONException(e.msg);
93+ }
94+ }
95+}
--- /dev/null
+++ b/source/pixivd/types/package.d
@@ -0,0 +1,10 @@
1+module pixivd.types;
2+
3+public
4+{
5+ import pixivd.types.illustration;
6+ import pixivd.types.pixiv_exception;
7+ import pixivd.types.thumbnail;
8+ import pixivd.types.ugoira;
9+ import pixivd.types.user;
10+}
--- /dev/null
+++ b/source/pixivd/types/pixiv_exception.d
@@ -0,0 +1,18 @@
1+module pixivd.types.pixiv_exception;
2+
3+class PixivException : Exception
4+{
5+ this(string msg)
6+ {
7+ super(msg);
8+ }
9+}
10+
11+/// Exception with the JSON response.
12+class PixivJSONException : PixivException
13+{
14+ this(string msg)
15+ {
16+ super("Error in JSON response: " ~ msg);
17+ }
18+}
--- /dev/null
+++ b/source/pixivd/types/thumbnail.d
@@ -0,0 +1,81 @@
1+module pixivd.types.thumbnail;
2+
3+import std.json;
4+
5+import pixivd.enums : ContentType;
6+import pixivd.mixins;
7+
8+class Thumbnail
9+{
10+ string id;
11+ string title;
12+ ContentType type;
13+ long xRestrict;
14+ long restrict;
15+ long sl;
16+ string url;
17+ string description;
18+ string[] tags;
19+ string userId;
20+ string userName;
21+ long width;
22+ long height;
23+ long pages;
24+ bool canBookmark;
25+ // bookmarkData;
26+ string alt;
27+ // titleCaptionTranslation
28+ string createDate;
29+ string updateDate;
30+ bool isUnlisted;
31+ bool isMasked;
32+ string[string] urls;
33+ string profileImageUrl;
34+
35+ static Thumbnail fromJSON(in ref JSONValue json)
36+ {
37+ auto thumb = new Thumbnail();
38+
39+ with (thumb)
40+ {
41+ id = json["id"].str;
42+ title = json["title"].str;
43+ type = cast(ContentType) json["illustType"].integer;
44+ xRestrict = json["xRestrict"].integer;
45+ restrict = json["restrict"].integer;
46+ sl = json["sl"].integer;
47+ url = json["url"].str;
48+ description = json["description"].str;
49+
50+ auto ptags = json["tags"].array;
51+ foreach (ref tag; ptags)
52+ {
53+ tags ~= tag.str;
54+ }
55+
56+ userId = json["userId"].str;
57+ userName = json["userName"].str;
58+ width = json["width"].integer;
59+ height = json["height"].integer;
60+ pages = json["pageCount"].integer;
61+ canBookmark = mixin(mixGetJsonBoolean!("json", "isBookmarkable"));
62+ // bookmarkData
63+ alt = json["alt"].str;
64+ // titleCaptionTranslation
65+ createDate = json["createDate"].str;
66+ updateDate = json["updateDate"].str;
67+ isUnlisted = mixin(mixGetJsonBoolean!("json", "isUnlisted"));
68+ isMasked = mixin(mixGetJsonBoolean!("json", "isMasked"));
69+
70+ auto purls = json["urls"];
71+ foreach(ref size; ["250x250", "360x360", "540x540"])
72+ {
73+ urls[size] = purls[size].str;
74+ }
75+
76+ profileImageUrl = json["profileImageUrl"].str;
77+ }
78+
79+ return thumb;
80+ }
81+}
--- /dev/null
+++ b/source/pixivd/types/ugoira.d
@@ -0,0 +1,72 @@
1+module pixivd.types.ugoira;
2+
3+import std.json;
4+
5+import pixivd.mixins;
6+import pixivd.types.pixiv_exception : PixivJSONException;
7+
8+class Ugoira
9+{
10+public:
11+
12+ struct Frame {
13+ immutable string file;
14+ immutable long delay;
15+ }
16+
17+private:
18+ string mimeType;
19+ string originalSource;
20+ string source;
21+ Frame[] frames;
22+
23+public:
24+ static Ugoira fromJSON(in ref JSONValue json) {
25+
26+ if (mixin(mixCheckJsonError!("json"))) {
27+ throw new PixivJSONException(json["message"].str);
28+ }
29+
30+ static if (__VERSION__ < 2083L) {
31+ JSON_TYPE arrayType = JSON_TYPE.ARRAY;
32+ } else {
33+ JSONType arrayType = JSONType.array;
34+ }
35+
36+ auto bdy = json["body"];
37+ auto ugoira = new Ugoira();
38+
39+ with (ugoira) {
40+ mimeType = bdy["mime_type"].str;
41+ originalSource = bdy["originalSrc"].str;
42+ source = bdy["src"].str;
43+
44+ auto jsonframes = bdy["frames"].array;
45+ foreach (const ref JSONValue frame; jsonframes) {
46+ ugoira.frames ~= Frame(frame["file"].str, frame["delay"].integer);
47+ }
48+ }
49+
50+ return ugoira;
51+ }
52+
53+ string getMimeType() const
54+ {
55+ return mimeType;
56+ }
57+
58+ Frame[] getFrames()
59+ {
60+ return frames;
61+ }
62+
63+ string getOriginalSource() const
64+ {
65+ return originalSource;
66+ }
67+
68+ string getSource() const
69+ {
70+ return source;
71+ }
72+}
--- /dev/null
+++ b/source/pixivd/types/user.d
@@ -0,0 +1,136 @@
1+module pixivd.types.user;
2+
3+import std.json;
4+
5+import pixivd.mixins;
6+
7+class User
8+{
9+public:
10+ bool acceptRequest;
11+ bool following;
12+ bool followed;
13+ // illusts;
14+ bool isBlocking;
15+ bool isMypixiv;
16+ // novels;
17+ string profileImageUrl;
18+ string userComment;
19+ string userId;
20+ string userName;
21+
22+ static User fromJSON(in ref JSONValue json)
23+ {
24+ auto user = new User();
25+
26+ with (user) {
27+ acceptRequest = mixin(mixGetJsonBoolean!("json", "acceptRequest"));
28+ following = mixin(mixGetJsonBoolean!("json", "following"));
29+ followed = mixin(mixGetJsonBoolean!("json", "followed"));
30+ isBlocking = mixin(mixGetJsonBoolean!("json", "isBlocking"));
31+ isMypixiv = mixin(mixGetJsonBoolean!("json", "isMypixiv"));
32+
33+ profileImageUrl = json["profileImageUrl"].str;
34+ userComment = json["userComment"].str;
35+ userId = json["userId"].str;
36+ userName = json["userName"].str;
37+ }
38+
39+ return user;
40+ }
41+}
42+
43+class FullUser : User
44+{
45+ public string profileImageUrlBig;
46+
47+ static FullUser fromJSON(in ref JSONValue json)
48+ {
49+ auto user = new FullUser();
50+
51+ with (user)
52+ {
53+ userId = json["userId"].str;
54+ userName = json["name"].str;
55+ profileImageUrl = json["image"].str;
56+ profileImageUrlBig = json["imageBig"].str;
57+
58+ following = mixin(mixGetJsonBoolean!("json", "isFollowed"));
59+ followed = mixin(mixGetJsonBoolean!("json", "followedBack"));
60+ }
61+
62+ return user;
63+ }
64+}
65+
66+/**
67+Information about a user and their illustrations, manga, novels, etc.
68+*/
69+class UserBrief
70+{
71+public:
72+ struct Bookmark
73+ {
74+ long illust;
75+ long novel;
76+ }
77+
78+ struct SiteWorksStatus
79+ {
80+ bool booth;
81+ bool sketch;
82+ bool vroidHub;
83+ }
84+
85+public:
86+ Bookmark[string] bookmarkCount;
87+ string[] illusts;
88+ string[] manga;
89+ // ?[] novels;
90+ // ?[] mangaSeries;
91+ // ?[] novelsSeries;
92+ // Illustration[](?) pickup;
93+ // ? request;
94+
95+ static UserBrief fromJSON(in ref JSONValue json)
96+ {
97+ auto ub = new UserBrief();
98+
99+ static if (__VERSION__ < 2083L) {
100+ JSON_TYPE arrayType = JSON_TYPE.ARRAY;
101+ } else {
102+ JSONType arrayType = JSONType.array;
103+ }
104+
105+ with(ub) {
106+ bookmarkCount["public"] = Bookmark(
107+ json["bookmarkCount"]["public"]["illust"].integer,
108+ json["bookmarkCount"]["public"]["novel"].integer
109+ );
110+ bookmarkCount["private"] = Bookmark(
111+ json["bookmarkCount"]["private"]["illust"].integer,
112+ json["bookmarkCount"]["private"]["novel"].integer
113+ );
114+
115+ /*
116+ * When an account doesn't have any illustrations or manga
117+ * then the return type is an empty array, however, if there
118+ * are illustrations or manga, then the type is an JSONObject.
119+ */
120+
121+ if ("illusts" in json && (arrayType != json["illusts"].type)) {
122+ foreach(k, v; json["illusts"].object) {
123+ illusts ~= k;
124+ }
125+ }
126+
127+ if ("manga" in json && (arrayType != json["manga"].type)) {
128+ foreach(k, v; json["manga"].object) {
129+ manga ~= k;
130+ }
131+ }
132+ }
133+
134+ return ub;
135+ }
136+}