123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- /*
- * pixiv_down - CLI-based downloading tool for https://www.pixiv.net.
- * Copyright (C) 2024 Mio
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, version 3 of the License.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
- module app.cmds.prune;
- import pd.configuration: Config;
- import pd.pixiv;
- public void displayPruneHelp()
- {
- import std.stdio : stderr;
- stderr.writefln(
- "pixiv_down prune - Prune previously followed accounts.\n" ~
- "usage: pixiv_down prune [options]\n" ~
- "\n" ~
- "The prune command walks through all existing directories containing\n"~
- "content from followed accounts. It then checks if you are still\n"~
- "following that account (or if the account still exists). If either\n"~
- "of these are not true, pixiv_down will prompt to move the directory\n"~
- "to the recycle bin.\n" ~
- "\n" ~
- "Options:\n" ~
- " -n, --dry-run \tPrint the directories that would be moved,\n" ~
- " \tbut do not actually move them.\n" ~
- " -q, --quiet \tDo not prompt to confirm deleting of files.\n" ~
- " -s, --silent \tSynonym of --quiet.\n" ~
- " -h, --help \tDisplay this help message and exit.\n");
- }
- public int pruneHandle(string[] args, in Config config)
- {
- import std.experimental.logger;
- import std.getopt : getopt, GetOptException, GetOptOption = config;
- import std.stdio : stderr;
- Options options;
- try {
- auto helpInformation = getopt(args,
- GetOptOption.bundling,
- "quiet|q|silent|s", &options.quiet,
- "dry-run|n", &options.dry_run);
- if (helpInformation.helpWanted) {
- displayPruneHelp();
- return 0;
- }
- } catch (GetOptException e) {
- stderr.writefln("pixiv_down prune: %s", e.msg);
- stderr.writefln("Run 'pixiv_down help prune' for more information.");
- return 1;
- }
- if (options.dry_run && options.quiet) {
- return 0;
- }
- infof("running `prune` quietly? %s", options.quiet);
- return runPrune(config, options);
- }
- private:
- // TODO: use std.sumtype
- struct Result(T)
- {
- ErrorKind error;
- T result;
- }
- enum ErrorKind
- {
- None,
- UserNotFound,
- UserNotFollowed,
- PixivError,
- UnknownError
- }
- struct Options
- {
- bool quiet;
- bool dry_run;
- }
- void reportProgress(long total, long current)
- {
- import mlib.term: Term;
- import std.format: format;
- import std.stdio: stdout;
- Term.clearCurrentLine();
- const ratioCompleted = cast(double)current / total;
- const prefix = format!"%d ["(current);
- const suffix = format!"] %3.0f%%"(ratioCompleted * 100);
- const barLength = Term.getColumnCount() - prefix.length - suffix.length;
- stdout.write(prefix);
- foreach(i; 0..barLength) {
- stdout.write(i < (ratioCompleted * barLength) ? '#' : ' ');
- }
- stdout.write(suffix);
- stdout.flush();
- }
- Result!(User[]) fetchFollowing(bool forPrivate, in Config config)
- {
- import pd.pixiv : p_fetchFolloing = fetchFollowing;
- import std.experimental.logger;
- User[] users;
- long total;
- const visibility = forPrivate ? "private" : "public";
- // Fetch public accounts.
- long offset = 0;
- do {
- try {
- User[] page = p_fetchFolloing(forPrivate, offset, total, config);
- if (page.length == 0) {
- tracef("early finish fetching %s followed accounts.", visibility);
- break;
- }
- offset += page.length;
- users ~= page;
- reportProgress(total, offset);
- } catch (PixivException e) {
- errorf("Failed to fetch %s following: %s", visibility, e.msg);
- return Result!(User[])(ErrorKind.PixivError);
- } catch (Exception e) {
- errorf("Unknown error when fetching %s following: %s", visibility, e.msg);
- return Result!(User[])(ErrorKind.UnknownError);
- }
- } while (offset < total);
- return Result!(User[])(ErrorKind.None, users);
- }
- /// adhoc set implementation using assocArray.
- class Set(E)
- {
- private void[0][E] data;
- void add(E e)
- {
- data.require(e);
- }
- E[] toArray() const
- {
- return data.keys;
- }
- size_t length() const
- {
- return data.length;
- }
- }
- struct UserPair
- {
- string id;
- string displayName;
- bool valid;
- }
- Set!string findMissingIds(User[] users, string outputDirectory)
- {
- import std.algorithm : countUntil, map;
- import std.ascii : isDigit;
- import std.experimental.logger;
- import std.file : SpanMode, dirEntries;
- import std.path : baseName;
- import std.string : split;
- Set!string missingIds = new Set!string();
- auto userIds = users.map!(u => u.id);
- foreach(dir; dirEntries(outputDirectory, SpanMode.shallow)) {
- const bname = baseName(dir);
- if (bname.length <= 0 || false == isDigit(bname[0])) {
- continue;
- }
- const id = bname.split('_')[0];
- if (userIds.countUntil(id) == -1) {
- infof("adding missing ID %s", id);
- missingIds.add(id);
- }
- }
- return missingIds;
- }
- Set!UserPair retrieveAccountInfo(in Set!string userIds, in Config conf)
- {
- import app.util: sleep;
- import std.experimental.logger;
- import std.json : JSONException;
- import pd.pixiv;
- Set!UserPair pairs;
- string[] ids = userIds.toArray();
- pairs = new Set!UserPair();
- foreach(index, id; ids) {
- try {
- auto user = fetchUser(id, conf);
- pairs.add(UserPair(id, user.userName, true));
- } catch (JSONException e) {
- // User does not exist.
- pairs.add(UserPair(id, "", false));
- } catch (Exception e) {
- errorf("failed to fetch user ID %s: %s", id, e.msg);
- }
- displayProgress(index, ids.length, "Retrieving account information");
- sleep(2, 4, false);
- }
- return pairs;
- }
- void displayProgress(ulong through, ulong total, string message = "")
- {
- import std.stdio : stderr;
- auto percent = (cast(float)through / total) * 100.0;
- message = (message == "") ? "Progress" : message;
- stderr.writef("\r\033[2K%s: %d/%d (%3.2f%%)", message, through, total,
- percent);
- stderr.flush();
- }
- int runPrune(in Config config, in Options options)
- {
- import app.util: sleep;
- import mlib.term;
- import std.stdio: stdout, stderr;
- int success;
- if (options.quiet) {
- auto publicAccts = fetchFollowing(false, config);
- if (publicAccts.error != ErrorKind.None) {
- return 1;
- }
- auto privateAccts = fetchFollowing(true, config);
- if (privateAccts.error != ErrorKind.None) {
- return 1;
- }
- auto users = publicAccts.result ~ privateAccts.result;
- auto missingIds = findMissingIds(users, config.outputDirectory);
- foreach(id; missingIds.toArray()) {
- success |= remove(id, config.outputDirectory, options.dry_run);
- }
- return success;
- }
- stdout.writeln("Retrieving public following account list...");
- Result!(User[]) publicAccts = fetchFollowing(/* forPrivate */ false, config);
- if (publicAccts.error != ErrorKind.None) {
- stderr.writefln("Failed to retrieve public followed accounts: %s", publicAccts.error);
- return 1;
- }
- Term.clearCurrentLine();
- Term.goUpAndClearLine(1);
- stdout.writeln("Fetched public followed accounts.");
- sleep(1, 10, false);
- stdout.writeln("Retrieving private following account list...");
- Result!(User[]) privateAccts = fetchFollowing(/* forPrivate */ true, config);
- if (privateAccts.error != ErrorKind.None) {
- stderr.writefln("Failed to retrieve private followed accounts: %s", privateAccts.error);
- }
- Term.clearCurrentLine();
- Term.goUpAndClearLine(1);
- stdout.writeln("Fetched private followed accounts.");
- User[] users = publicAccts.result ~ privateAccts.result;
- auto missingIds = findMissingIds(users, config.outputDirectory);
- auto missingAccounts = retrieveAccountInfo(missingIds, config);
- Term.goUpAndClearLine(1);
- foreach(account; missingAccounts.toArray()) {
- auto result = removeAccount(account, config, options.dry_run);
- success |= result.success;
- /* Clear last two lines */
- Term.goUpAndClearLine(1, Yes.useStderr);
- Term.goUpAndClearLine(1, Yes.useStderr);
- if (result.accountRemoved && !options.dry_run) {
- if (account.valid) {
- stdout.writefln("Removed directories for %s", account.displayName);
- } else {
- stdout.writefln("Removed directories for ID %s", account.id);
- }
- } else if (result.accountRemoved && options.dry_run) {
- if (account.valid) {
- stdout.writefln("Would have removed directories for %s", account.displayName);
- } else {
- stdout.writefln("Would have removed directories for ID %s", account.id);
- }
- }
- }
- return success;
- }
- bool prompt(string msg)
- {
- import std.stdio : readln, writef;
- import std.string : toLower, strip;
- writef("%s [y/N]: ", msg);
- string res = readln.strip.toLower();
- if (res == "yes" || res == "y") {
- return true;
- }
- return false;
- }
- struct RemoveAccountReturn
- {
- /// Did the process execute successfully.
- bool success;
- /// Were any directories removed?
- bool accountRemoved;
- }
- RemoveAccountReturn removeAccount(UserPair user, in Config config, bool dryRun)
- {
- import std.experimental.logger;
- import std.stdio : writefln;
- bool removed = false;
- immutable promptMessage = dryRun ?
- "Would you want to remove their directories?" :
- "Do you want to remove their directories?";
- tracef("checkAndRemove(UserPair(%s, %s, %d))", user.id, user.displayName, user.valid);
- if (user.valid) {
- writefln("Not following %s (ID %s)", user.displayName, user.id);
- if (prompt(promptMessage)) {
- removed = remove(user.id, config.outputDirectory, dryRun) == 0;
- }
- return RemoveAccountReturn(true, removed);
- }
- writefln("User with ID %s has left pixiv.", user.id);
- if (prompt(promptMessage)) {
- removed = remove(user.id, config.outputDirectory, dryRun) == 0;
- }
- return RemoveAccountReturn(true, removed);
- }
- /// Remove all directories matching the pattern `id_` within the
- /// directory *baseDirectory*.
- ///
- /// If *dryRun* is `true`, no directories will be removed.
- int remove(string id, string baseDirectory, bool dryRun)
- {
- import std.file : SpanMode, dirEntries;
- import mlib.trash : trash;
- immutable pattern = id ~ "_*";
- if (dryRun) {
- return 0;
- }
- foreach(dir; dirEntries(baseDirectory, pattern, SpanMode.shallow)) {
- trash(dir);
- }
- return 0;
- }
|