This model permits users to:
7 | {%- if allowNoCredit %} {{ chrCheck }} {% else %} {{ chrCross }} {% endif %} : Use the model without crediting the creator
8 | {%- if canSellImages %} {{ chrCheck }} {% else %} {{ chrCross }} {% endif %} : Sell images they generate
9 | {%- if canRent %} {{ chrCheck }} {% else %} {{ chrCross }} {% endif %} : Run on services that generate images for money
10 | {%- if canRentCivit %} {{ chrCheck }} {% else %} {{ chrCross }} {% endif %} : Run on Civitai
11 | {%- if allowDerivatives %} {{ chrCheck }} {% else %} {{ chrCross }} {% endif %} : Share merges using this model
12 | {%- if canSell %} {{ chrCheck }} {% else %} {{ chrCross }} {% endif %} : Sell this model or merges using this model
13 | {%- if allowDifferentLicense %} {{ chrCheck }} {% else %} {{ chrCross }} {% endif %} : Have different permissions when sharing merges model
14 |
15 |
16 |
17 |
18 | {%- if not allowNoCredit %} Creator credit required {% endif %}
19 | {%- if not canSellImages %} No selling images {% endif %}
20 | {%- if not canRentCivit %} No Civitai generation {% endif %}
21 | {%- if not canRent %} No generation services {% endif %}
22 | {%- if not canSell %} No selling models {% endif %}
23 | {%- if not allowDerivatives %} No sharing merges {% endif %}
24 | {%- if not allowDifferentLicense %} Same permissions required {% endif %}
25 |
"
33 | "Favourite creators and banned creators are stored in text files `favoriteUsers.txt` and `bannedUsers.txt` respectively. "
34 | "To register the creator, go to User Management on the CivBrowser tab. "
35 | "The previous registration items in here have been discontinued. "
36 | "Previously registered users are re-registered every time the app is started. "
37 | "Therefore, it is not possible to delete previously registered users. "
38 | "To avoid this, edit your `config.json` file and remove the `civsfz_favorite_creators` and `civsfz_ban_creators` lines."
39 | ),
40 | }
41 | if not platform == "SD.Next"
42 | else {}
43 | )
44 |
45 | dict_api_info = (
46 | {
47 | "civsfz_msg_html1": shared.OptionHTML(
48 | "
API-Key
"
49 | "The command line option `--civsfz-api-key` is deprecated. "
50 | "We felt that this was a risk because some users might not notice the API-Key being displayed on the console. "
51 | "The API-Key here is saved in the `config.json` file."
52 | "If you do not know this, there is a risk of your API-Key being leaked."
53 | ),
54 | }
55 | if not platform == "SD.Next"
56 | else {}
57 | )
58 |
59 | dict_options1 = {
60 | "civsfz_api_key": shared.OptionInfo(
61 | "",
62 | label="API-Key",
63 | component=gr.Textbox,
64 | component_args={"type": "password"},
65 | ).info("Note: API-Key is stored in the `config.json` file."),
66 | "civsfz_request_timeout": shared.OptionInfo(
67 | 30,
68 | label="Request timeout(s)",
69 | component=gr.Slider,
70 | component_args={"minimum": 10, "maximum": 90, "step": 5},
71 | ),
72 | "civsfz_browsing_level": shared.OptionInfo(
73 | [1],
74 | label="Browsing level",
75 | component=gr.CheckboxGroup,
76 | component_args={
77 | "choices": list(Api.nsfwLevel.items()),
78 | },
79 | ),
80 | "civsfz_number_of_tabs": shared.OptionInfo(
81 | 3,
82 | label="Number of tabs",
83 | component=gr.Slider,
84 | component_args={"minimum": 1, "maximum": 8, "step": 1},
85 | ).needs_reload_ui(),
86 | "civsfz_number_of_cards": shared.OptionInfo(
87 | 12,
88 | label="Number of cards per page",
89 | component=gr.Slider,
90 | component_args={"minimum": 8, "maximum": 48, "step": 1},
91 | ),
92 | "civsfz_card_size_width": shared.OptionInfo(
93 | 8,
94 | label="Card width (unit:1em)",
95 | component=gr.Slider,
96 | component_args={"minimum": 5, "maximum": 30, "step": 1},
97 | ),
98 | "civsfz_card_size_height": shared.OptionInfo(
99 | 12,
100 | label="Card height (unit:1em)",
101 | component=gr.Slider,
102 | component_args={"minimum": 5, "maximum": 30, "step": 1},
103 | ),
104 | "civsfz_hover_zoom_magnification": shared.OptionInfo(
105 | 1.5,
106 | label="Zoom magnification when hovering",
107 | component=gr.Slider,
108 | component_args={"minimum": 1, "maximum": 2.4, "step": 0.1},
109 | ),
110 | "civsfz_treat_x_as_nsfw": shared.OptionInfo(
111 | True,
112 | label='If the first image is of type "X", treat the model as nsfw',
113 | component=gr.Checkbox,
114 | ),
115 | "civsfz_treat_slash_as_folder_separator": shared.OptionInfo(
116 | False,
117 | label=r'Treat "/" as folder separator. If you change this, some models may not be able to confirm the existence of the file.',
118 | component=gr.Checkbox,
119 | ),
120 | "civsfz_discard_different_hash": shared.OptionInfo(
121 | True,
122 | label="Discard downloaded model file if the hash value differs",
123 | component=gr.Checkbox,
124 | ),
125 | "civsfz_overwrite_metadata_file": shared.OptionInfo(
126 | False,
127 | label="Allow metadata files to be overwritten. (xxx.txt/xxx.json)",
128 | component=gr.Checkbox,
129 | ),
130 | "civsfz_length_of_conditions_history": shared.OptionInfo(
131 | 5,
132 | label="Length of conditions history",
133 | component=gr.Slider,
134 | component_args={"minimum": 5, "maximum": 10, "step": 1},
135 | ),
136 | "civsfz_length_of_search_history": shared.OptionInfo(
137 | 5,
138 | label="Length of search term history",
139 | component=gr.Slider,
140 | component_args={"minimum": 5, "maximum": 30, "step": 1},
141 | ),
142 | }
143 |
144 | dict_background_opacity = {
145 | "civsfz_background_opacity": shared.OptionInfo(
146 | 0.75,
147 | label="Background opacity for model name",
148 | component=gr.Slider,
149 | component_args={"minimum": 0.0, "maximum": 1.0, "step": 0.05},
150 | ),
151 | }
152 |
153 | dict_shadow_color = {
154 | "civsfz_shadow_color_default": shared.OptionInfo(
155 | "#798a9f",
156 | label="Frame color for cards",
157 | component=gr.ColorPicker,
158 | ),
159 | "civsfz_shadow_color_alreadyhave": shared.OptionInfo(
160 | "#7fffd4",
161 | label="Frame color for cards you already have",
162 | component=gr.ColorPicker,
163 | ),
164 | "civsfz_shadow_color_alreadyhad": shared.OptionInfo(
165 | "#caff7f",
166 | label="Frame color for cards with updates",
167 | component=gr.ColorPicker,
168 | ),
169 | }
170 | dict_folder_info = (
171 | {
172 | "civsfz_msg_html2": shared.OptionHTML(
173 | "
How to set subfolders
"
174 | "Click here(Wiki|GitHub) to learn how to specify the model folder and subfolders."
175 | ),
176 | }
177 | if not platform == "SD.Next"
178 | else {}
179 | )
180 |
181 | label = 'Save folders for Types. Set JSON Key-Value pair. The folder separator is "/" not "\\". The path is relative from models folder or absolute.'
182 | info = 'Example for Stability Matrix: {"Checkpoint":"Stable-diffusion/sd"}'
183 | placefolder = '{\n\
184 | "Checkpoint": "MY_SUBFOLDER",\n\
185 | "VAE": "",\n\
186 | "TextualInversion": "",\n\
187 | "LORA": "",\n\
188 | "LoCon": "",\n\
189 | "Hypernetwork": "",\n\
190 | "AestheticGradient": "",\n\
191 | "Controlnet": "ControlNet",\n\
192 | "Upscaler": "OtherModels/Upscaler",\n\
193 | "MotionModule": "OtherModels/MotionModule",\n\
194 | "Poses": "OtherModels/Poses",\n\
195 | "Wildcards": "OtherModels/Wildcards",\n\
196 | "Workflows": "OtherModels/Workflows",\n\
197 | "Other": "OtherModels/Other"\n\
198 | }'
199 |
200 | dict_folders = {
201 | "civsfz_save_type_folders": (
202 | shared.OptionInfo(
203 | "",
204 | label=label + " " + info,
205 | comment_after=None,
206 | component=gr.Code,
207 | component_args=lambda: {
208 | "language": "python",
209 | "interactive": True,
210 | "lines": 4,
211 | },
212 | )
213 | if GR_V440
214 | else shared.OptionInfo(
215 | "",
216 | label=label,
217 | component=gr.Textbox,
218 | component_args={
219 | "lines": 4,
220 | "info": info,
221 | "placeholder": placefolder,
222 | },
223 | )
224 | ),
225 | "civsfz_save_subfolder": shared.OptionInfo(
226 | "",
227 | label='Subfolders under type folders. Model information can be referenced by the key name enclosed in double curly brachets "{{}}". Available key names are "BASEMODELbkCmpt", "BASEMODEL", "NSFW", "USERNAME", "MODELNAME", "MODELID", "VERSIONNAME" and "VERSIONID". Folder separator is "/".',
228 | component=gr.Textbox,
229 | component_args={
230 | "lines": 1,
231 | "placeholder": "_{{BASEMODEL}}/.{{NSFW}}/{{MODELNAME}}",
232 | },
233 | ),
234 | }
235 | dict_color_figcaption = {
236 | "civsfz_background_color_figcaption": shared.OptionInfo(
237 | "#414758",
238 | label="Background color for model name",
239 | component=gr.ColorPicker,
240 | ),
241 | }
242 |
243 | dict_color_family = (
244 | {
245 | "civsfz_msg_html3": shared.OptionHTML(
246 | "
What is Family?
"
247 | "Families are groups of similar base models. "
248 | "You can register the base model to the family. "
249 | "You can set the color for each color family. "
250 | "Colors within a family will automatically change based on the family color. "
251 | "The color changes gradually according to the hls color wheel."
252 | ),
253 | }
254 | if not platform == "SD.Next"
255 | else {}
256 | )
257 | for k in familyColor.keys():
258 | if k not in "non_family":
259 | dict_color_family |= {
260 | "civsfz_"
261 | + k: shared.OptionInfo(
262 | familyColor[k]["value"],
263 | label=k.capitalize(),
264 | component=ui_components.DropdownMulti, # Prevent multiselect error on A1111 v1.10.0
265 | component_args={
266 | "choices": Api.getBasemodelOptions(),
267 | },
268 | ),
269 | "civsfz_color_"
270 | + k: shared.OptionInfo(
271 | familyColor[k]["color"],
272 | label=k.capitalize() + " color",
273 | component=gr.ColorPicker,
274 | ).js("preview", "civsfz_preview_colors"),
275 | }
276 | else:
277 | dict_color_family |= {
278 | "civsfz_color_"
279 | + k: shared.OptionInfo(
280 | familyColor[k]["color"],
281 | label="Non-family color",
282 | component=gr.ColorPicker,
283 | ),
284 | }
285 |
286 | for key, opt in {
287 | **dict_user_management,
288 | **dict_api_info,
289 | **dict_options1,
290 | **dict_background_opacity,
291 | **dict_color_figcaption,
292 | **dict_color_family,
293 | **dict_shadow_color,
294 | **dict_folder_info,
295 | **dict_folders,
296 | }.items():
297 | opt.section = civsfz_section
298 | shared.opts.add_option(key, opt)
299 |
300 |
301 | script_callbacks.on_ui_settings(on_ui_settings)
302 |
--------------------------------------------------------------------------------
/scripts/civsfz_downloader.py:
--------------------------------------------------------------------------------
1 | import gradio as gr
2 | import math
3 | import os
4 | import re
5 | import requests
6 | from colorama import Fore, Back, Style
7 | from collections import deque
8 | from datetime import datetime, timedelta, timezone
9 | from jinja2 import Environment, FileSystemLoader
10 | from pathlib import Path
11 | from threading import Thread, local
12 | from time import sleep
13 | from tqdm import tqdm
14 | from scripts.civsfz_shared import opts, calculate_sha256, read_timeout
15 | from scripts.civsfz_filemanage import (
16 | makedirs,
17 | removeFile,
18 | extensionFolder,
19 | open_folder,
20 | filename_normalization,
21 | )
22 |
23 | def print_ly(x): return print(Fore.LIGHTYELLOW_EX +
24 | "CivBrowser: " + x + Style.RESET_ALL)
25 | def print_lc(x): return print(Fore.LIGHTCYAN_EX +
26 | "CivBrowser: " + x + Style.RESET_ALL)
27 | def print_n(x): return print("CivBrowser: " + x)
28 |
29 | class Downloader:
30 | _dlQ = deque() # Download
31 | _threadQ = deque() # Downloading
32 | _ctrlQ = deque() # control
33 | # _msgQ = deque() # msg from worker
34 | _thread_local = local()
35 | _dlResults = deque() # Download results
36 | _maxThreadNum = 2
37 | _threadNum = 0
38 |
39 | def __init__(self) -> None:
40 | Downloader._thread_local.progress = 0
41 |
42 | def get_session(self) -> requests.Session:
43 | if not hasattr(Downloader._thread_local, 'session'):
44 | Downloader._thread_local.session = requests.Session()
45 | return Downloader._thread_local.session
46 |
47 | def add(self, folder, filename, url, hash, api_key, early_access):
48 | filename = filename_normalization(filename)
49 | if Downloader._threadNum == 0:
50 | # Clear queue because garbage may remain due to errors that cannot be caught
51 | Downloader._threadQ.clear()
52 | path = Path(folder, filename)
53 | Downloader._ctrlQ.clear() # Clear cancel request
54 | if (not any(item['path'] == path for item in Downloader._dlQ)
55 | and not any(item['path'] == path for item in Downloader._threadQ)):
56 | Downloader._dlQ.append({"folder": folder,
57 | "filename": filename,
58 | "path": path,
59 | "url": url,
60 | "hash": hash,
61 | "apiKey": api_key,
62 | "EarlyAccess": early_access
63 | })
64 | if Downloader._threadNum < Downloader._maxThreadNum:
65 | Downloader._threadNum += 1
66 | worker = Thread(target=self.download)
67 | worker.start()
68 | else:
69 | return "Already in queue"
70 | return f"Queue {len(Downloader._dlQ)}: Threads {Downloader._threadNum}"
71 |
72 | def sendCancel(self, path:Path):
73 | '''
74 | Cancel downloading by file path
75 | '''
76 | # path = Path(folder, filename)
77 | delete = None
78 | for dl in Downloader._dlQ:
79 | if path == dl["path"]:
80 | delete = dl
81 | break
82 | if delete is None:
83 | Downloader._ctrlQ.append({"control": "cancel", "path": path})
84 | else:
85 | Downloader._dlQ.remove(dl)
86 | print_lc(f"Canceled:{path}")
87 | return f"Canceled:{path}"
88 |
89 | def status(self):
90 | now = datetime.now(timezone.utc)
91 | # Discard past results
92 | expireQ = deque()
93 | remove = None
94 | deadline = 3 * 60
95 | for item in Downloader._dlResults:
96 | tdDiff = now - item['completedAt']
97 | secDiff = math.ceil(abs(tdDiff / timedelta(seconds=1)))
98 | item['expiration'] = secDiff / deadline
99 | if secDiff < deadline:
100 | expireQ.append(item)
101 | else:
102 | remove = item
103 | if remove is not None:
104 | Downloader._dlResults.remove(remove)
105 |
106 | templatesPath = Path.joinpath(
107 | extensionFolder(), Path("../templates"))
108 | environment = Environment(
109 | loader=FileSystemLoader(templatesPath.resolve()),
110 | extensions=["jinja2.ext.loopcontrols"],
111 | )
112 | template = environment.get_template("downloadQueue.jinja")
113 | content = template.render(
114 | threadQ=Downloader._threadQ, waitQ=Downloader._dlQ, resultQ=expireQ)
115 | return content
116 |
117 | def dlHtml(self):
118 | html = self.status()
119 | return html
120 | def uiDlList(self, gr:gr, every:float=None):
121 | grHtmlDlQueue = gr.HTML(elem_id=f"civsfz_download_queue", value=lambda: self.dlHtml(), every=every)
122 | return grHtmlDlQueue
123 |
124 | def uiJsEvent(self, gr: gr):
125 | # Cancel Download item
126 | grTxtJsEventDl = gr.Textbox(
127 | label="Event text",
128 | value=None,
129 | elem_id="civsfz_eventtext_dl",
130 | visible=False,
131 | interactive=True,
132 | lines=1,
133 | )
134 | def eventDl(grTxtJsEventDl):
135 | command = grTxtJsEventDl.split("??")
136 | if command[0].startswith("CancelDl"):
137 | path = Path(command[1])
138 | self.sendCancel(path)
139 | gr.Info(f"Cancel")
140 | elif command[0].startswith("OpenFolder"):
141 | path = Path(command[1])
142 | open_folder(path)
143 | gr.Info(f"Open folder")
144 | return
145 |
146 | grTxtJsEventDl.change(
147 | fn=eventDl,
148 | inputs=[grTxtJsEventDl],
149 | outputs=[],
150 | )
151 |
152 | def download(self) -> None:
153 | session = self.get_session()
154 | result = "" # Success or Error
155 | while len(Downloader._dlQ) > 0:
156 | q = Downloader._dlQ.popleft()
157 | q['progress'] = 0 # add progress info
158 | Downloader._threadQ.append(q) # for download list
159 | url = q["url"]
160 | folder = q["folder"]
161 | filename = q["filename"]
162 | hash = q["hash"]
163 | api_key = q["apiKey"]
164 | early_access = q["EarlyAccess"]
165 | #
166 | makedirs(folder)
167 | file_name = os.path.join(folder, filename)
168 | # Maximum number of retries
169 | max_retries = 3
170 | # Delay between retries (in seconds)
171 | retry_delay = 3
172 |
173 | downloaded_size = 0
174 | headers = {
175 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0'
176 | }
177 | applyAPI = False # True if API key is added
178 | mode = "wb" # Open file mode
179 | if os.path.exists(file_name):
180 | print_lc("Overwrite")
181 |
182 | # Split filename from included path
183 | tokens = re.split(re.escape('\\'), file_name)
184 | file_name_display = tokens[-1]
185 | cancel = False
186 | exitDownloading = False
187 | while not exitDownloading:
188 | # Send a GET request to the URL and save the response to the local file
189 | try:
190 | # Get the total size of the file
191 | with session.get(
192 | url,
193 | headers=headers,
194 | stream=True,
195 | timeout=read_timeout(),
196 | ) as response:
197 | response.raise_for_status()
198 | # print_lc(f"{response.headers=}")
199 | if 'Content-Length' in response.headers:
200 | total_size = int(
201 | response.headers.get("Content-Length", 0))
202 | # Update the total size of the progress bar if the `Content-Length` header is present
203 | if total_size == 0:
204 | total_size = downloaded_size
205 | with tqdm(total=1000000000, unit="B", unit_scale=True, desc=f"Downloading {file_name_display}", initial=downloaded_size, leave=True) as progressConsole:
206 | prg = 0 # downloaded_size
207 | progressConsole.total = total_size
208 | with open(file_name, mode) as file:
209 | for chunk in response.iter_content(chunk_size=1*1024*1024):
210 | if chunk: # filter out keep-alive new chunks
211 | file.write(chunk)
212 | progressConsole.update(len(chunk))
213 | prg += len(chunk)
214 | # update progress
215 | i = Downloader._threadQ.index(q)
216 | q['progress'] = prg / total_size
217 | Downloader._threadQ[i] = q
218 | # cancel
219 | if len(Downloader._ctrlQ) > 0:
220 | ctrl = Downloader._ctrlQ[0]
221 | if ctrl["control"] == "cancel" and Path(folder, filename) == Path(ctrl["path"]) :
222 | Downloader._ctrlQ.popleft()
223 | exitDownloading = True
224 | cancel = True
225 | result = "Canceled"
226 | print_lc(
227 | f"Canceled:{file_name_display}:")
228 | break
229 | downloaded_size = os.path.getsize(file_name)
230 | # Break out of the loop if the download is successful
231 | break
232 | else:
233 | # if early_access and applyAPI:
234 | # print_ly(
235 | # f"{file_name_display}:Download canceled. Early Access!")
236 | # exitDownloading = True
237 | # result = "Early Access"
238 | # break
239 | if not applyAPI:
240 | print_lc("May need API key")
241 | if len(api_key) == 32:
242 | headers.update(
243 | {"Authorization": f"Bearer {api_key}"})
244 | applyAPI = True
245 | print_lc(f"{file_name_display}:Apply API key")
246 | else:
247 | exitDownloading = True
248 | result = "No API key"
249 | break
250 | else:
251 | exitDownloading = True
252 | print_lc(
253 | f"{file_name_display}:Invalid API key or Early Access"
254 | )
255 | result = "Invalid API key or Early Access"
256 | break
257 |
258 | except requests.exceptions.Timeout as e:
259 | print_ly(f"{file_name_display}:{e}")
260 | result = "Timeout"
261 | except ConnectionError as e:
262 | print_ly(f"{file_name_display}:{e}")
263 | result = "Connection Error"
264 | except requests.exceptions.RequestException as e:
265 | print_ly(f"{file_name_display}:{e}")
266 | result = "Request exception"
267 | except Exception as e:
268 | print_ly(f"{file_name_display}:{e}")
269 | result = "Exception"
270 | exitDownloading = True
271 | # Decrement the number of retries
272 | max_retries -= 1
273 | # If there are no more retries, raise the exception
274 | if max_retries == 0:
275 | exitDownloading = True
276 | result += "(Max retry failure)"
277 | break
278 | # Wait for the specified delay before retrying
279 | print_lc(f"{file_name_display}:Retry wait {retry_delay}s")
280 | sleep(retry_delay)
281 |
282 | if exitDownloading:
283 | if cancel:
284 | print_lc(f'Canceled : {file_name_display}')
285 | # gr.Warning(f"Canceled: {file_name_display}")
286 | if os.path.exists(file_name):
287 | removeFile(file_name)
288 | else:
289 | if os.path.exists(file_name):
290 | # downloaded_size = os.path.getsize(file_name)
291 | # Check if the download was successful
292 | sha256 = calculate_sha256(file_name).upper()
293 | print_lc(f'Downloaded hash : {sha256}')
294 | if hash != "":
295 | if sha256[:len(hash)] == hash.upper():
296 | print_n(f"Save: {file_name_display}")
297 | result = "Succeeded"
298 | # gr.Info(f"Success: {file_name_display}")
299 | else:
300 | print_lc(f'Model file hash : {hash}')
301 | print_ly(f"Hash mismatch. {file_name_display}")
302 | # gr.Warning(f"Hash mismatch: {file_name_display}")
303 | if opts.civsfz_discard_different_hash:
304 | removeFile(file_name)
305 | else:
306 | print_ly("Not trashed due to your setting.")
307 | result = "Hash mismatch"
308 | else:
309 | print_n(f"Save: {file_name_display}")
310 | print_ly("No hash value provided. Unable to confirm file.")
311 | result = "No hash value"
312 | # gr.Info(f"No hash: {file_name_display}")
313 | # Downloader._dlQ.task_done()
314 | Downloader._threadQ.remove(q)
315 | q['result'] = result
316 | q['completedAt'] = datetime.now(timezone.utc)
317 | Downloader._dlResults.append(q)
318 | Downloader._threadNum -= 1
319 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # sd-civitai-browser (CivBrowser)
2 |
3 | An extension to help download models from CivitAi without leaving WebUI
4 |
5 | ## Modifications
6 |
7 |
8 |
9 | ***If you fork, please replace prefix `civsfz` in file names, function names, css class names, etc. with your own prefix to avoid conflicts.***
10 |
11 | ## Features
12 |
13 | - Works with ***A1111***, ***Forge***, ***SD.Next***, ***Forge Neo***(experimental)(above v2.3.5)
14 | - SD.Next is far away from A1111, so it may stop working eventually
15 | - Search Civitai models in ***multiple tabs***
16 | - Download ***queue*** and ***multithreaded*** downloads
17 | - List models as ***card image***
18 | - Safe display in ***sfw*** search
19 | - Highlight models ***you have***
20 | - Highlight models that ***can be updated***
21 | - Searchable by specifying ***Base Models***
22 | - Show ***Base Model*** on a model card
23 | - Display/***save*** model information ***in HTML***
24 | - ***Json data*** of model is also ***saved*** in the same folder as the model file
25 | - If the sample image has meta data, display it with ***infotext compatibility***
26 | - Click on the image to ***send to txt2img***
27 | - Check downloaded model file with ***SHA256***
28 | - ***Automatically*** set save folder, or specify it directly
29 | - Card ***size and colors*** can be changed in Settings
30 | - Support ***API Key***
31 | - ~~Show the ***Early Access*** period of models~~
32 | - Ban creators feature (Hide models by creator names)
33 | - Favorite creators feature (Highlight the model with ⭐️)
34 | - Stores metadata.
35 | - Create description and activation text from model information.
36 | - If metadata already exists, it will not be overwritten. Can be changed in settings.
37 |
38 | ## Installation
39 |
40 | 1. Launch SD-webUI.
41 | 2. Open tab `Extensions` on SD-webUI.
42 | 3. Open tab `Available`.
43 | 4. Click `Load from:` button.
44 | 5. Search `civbrowser`.
45 | 6. Click `install` button.
46 | 7. Wait for the installation to complete.
47 | 8. Open tab `Installed`.
48 | 9. Click `Apply and quit` button.
49 | 10. Restart SD-webUI.
50 |
51 | ## Versions
52 |
53 | ### v2.9
54 |
55 | - Add support for Forge Neo (experimental)
56 | - Video models are treated the same as SD models.
57 | - The compatibility with ComfyUI, such as unet and diffusion_models folders, is undecided.
58 | - Separate folders are required for gguf and safetensors, but this cannot be determined due to insufficient model information.
59 |
60 | ### v2.8
61 |
62 | - Stores metadata.
63 | - Create description and activation text from model information.
64 | - If metadata already exists, it will not be overwritten. Can be changed in settings.
65 | - You can now search using keywords, usernames, and tags at the same time.
66 | - Searching for Favorites on Civitai.
67 | - Some model information can now be collapsed and hidden. Works in modern browsers.
68 |
69 | ### v2.7
70 |
71 | - Update of option acquisitions due to API response change.
72 |
73 | #### v2.7.5
74 |
75 | - Support for changing search response by model name. Pageneation is back.
76 |
77 | #### v2.7.4
78 |
79 | - The maximum number of cards per page when searching model names is now 100. There is no second page because the API response does not include pagination. This is a temporary change.
80 |
81 | #### [reference](https://github.com/civitai/civitai/blob/main/src/components/PermissionIndicator/PermissionIndicator.tsx#L15)
377 | - Avoid collision when there are same model names
378 | - Deprecate new folder checkbox and its function
379 | - Change version selection from dropdown to radio button
380 |
381 | ### v1.1.0 and before
382 |
383 | - Apply changes made by [thetrebor](https://github.com/thetrebor/sd-civitai-browser)
384 | - Support LoRA
385 | - Set folders from cmd_opts
386 | - Save HTML with images
387 | - Add Trained Tags in html
388 | - Add meta data in html
389 | - Copy first image as thumbnail
390 | - Add thumbnail preview list
391 | - Save model data as ".civitai.info"
392 | - Support LoCon/LyCORIS
393 | - Press the Download Model button again to cancel the download
394 | - Add previous button
395 | - Click on a thumbnail to select a model
396 | - Add zoom effect to thumbnails
397 | - Support ControlNet/Poses
398 | - Support user and tag name search
399 | - Implement page controls
400 | - Support for `--lyco-dir`(To use the existing _LoCon folder, specify with `--lyco-dir`)
401 | - Change the color of the frame of the card that has already been downloaded
402 | - For base models other than SD1, save to a subfolder of the base model name
403 | - Support `--lyco-dir-backcompat` modified in v1.5.1 of SD web UI
404 | - Save to subfolder of base model name (e.g. `_SDXL_1_0` , `_Other`)
405 |
--------------------------------------------------------------------------------
/scripts/civsfz_filemanage.py:
--------------------------------------------------------------------------------
1 | import os
2 | import requests
3 | import urllib.parse
4 | import shutil
5 | import json
6 | import re
7 | from pathlib import Path
8 | import platform
9 | import subprocess as sp
10 | from collections import deque
11 | from modules import sd_models
12 | from colorama import Fore, Back, Style
13 | from scripts.civsfz_shared import cmd_opts, opts, read_timeout
14 | from modules.paths import models_path
15 | try:
16 | from send2trash import send2trash
17 | send2trash_installed = True
18 | except ImportError:
19 | print("Recycle bin cannot be used.")
20 | send2trash_installed = False
21 |
22 | print_ly = lambda x: print(Fore.LIGHTYELLOW_EX + "CivBrowser: " + x + Style.RESET_ALL )
23 | print_lc = lambda x: print(Fore.LIGHTCYAN_EX + "CivBrowser: " + x + Style.RESET_ALL )
24 | print_n = lambda x: print("CivBrowser: " + x )
25 |
26 | isDownloading = False
27 | ckpt_dir = cmd_opts.ckpt_dir or sd_models.model_path
28 | pre_opt_folder = None
29 |
30 | def extensionFolder() -> Path:
31 | return Path(__file__).parent
32 |
33 | def name_len(s:str):
34 | #return len(s.encode('utf-16'))
35 | return len(s.encode('utf-8'))
36 | def cut_name(s:str):
37 | MAX_FILENAME_LENGTH = 246
38 | l = name_len(s)
39 | #print_lc(f'filename length:{len(s.encode("utf-8"))}-{len(s.encode("utf-16"))}')
40 | while l >= MAX_FILENAME_LENGTH:
41 | s = s[:-1]
42 | l = name_len(s)
43 | return s
44 |
45 |
46 | def escaped_filename(model_name):
47 | escapechars = str.maketrans({ " ": r"_",
48 | "(": r"",
49 | ")": r"",
50 | "|": r"",
51 | ":": r"",
52 | ",": r"_",
53 | "<": r"",
54 | ">": r"",
55 | "!": r"",
56 | "?": r"",
57 | ".": r"_",
58 | "&": r"_and_",
59 | "*": r"_",
60 | "\"": r"",
61 | "\\": r"",
62 | "\t":r"_",
63 | "/": r"/" if opts.civsfz_treat_slash_as_folder_separator else r"_"
64 | })
65 | new_name = model_name.translate(escapechars)
66 | new_name = re.sub('_+', '_', new_name)
67 | new_name = cut_name(new_name)
68 | return new_name
69 |
70 |
71 | def type_path(type: str) -> Path:
72 | global pre_opt_folder, ckpt_dir
73 | if opts.civsfz_save_type_folders != "":
74 | try:
75 | folderSetting = json.loads(opts.civsfz_save_type_folders)
76 | except json.JSONDecodeError as e:
77 | if pre_opt_folder != opts.civsfz_save_type_folders:
78 | print_ly(f'Check subfolder setting: {e}')
79 | folderSetting = {}
80 | else:
81 | folderSetting = {}
82 | pre_opt_folder = opts.civsfz_save_type_folders
83 | base = models_path
84 | if type == "Checkpoint":
85 | default = ckpt_dir
86 | # folder = ckpt_dir
87 | folder = os.path.relpath(default, base) # os.path.join(models_path, "Stable-diffusion")
88 | elif type == "Hypernetwork":
89 | default = cmd_opts.hypernetwork_dir
90 | folder = os.path.relpath(default, base)
91 | elif type == "TextualInversion":
92 | default = cmd_opts.embeddings_dir
93 | folder = os.path.relpath(default, base)
94 | elif type == "AestheticGradient":
95 | default = os.path.join(models_path, "../extensions/stable-diffusion-webui-aesthetic-gradients/aesthetic_embeddings")
96 | folder = os.path.relpath(default, base)
97 | elif type == "LORA":
98 | default = cmd_opts.lora_dir # "models/Lora"
99 | folder = os.path.relpath(default, base)
100 | elif type == "LoCon":
101 | #if "lyco_dir" in cmd_opts:
102 | # default = f"{cmd_opts.lyco_dir}"
103 | #elif "lyco_dir_backcompat" in cmd_opts: # A1111 V1.5.1
104 | # default = f"{cmd_opts.lyco_dir_backcompat}"
105 | #else:
106 | default = os.path.join(models_path, "Lora/_LyCORIS")
107 | folder = os.path.relpath(default, base)
108 | elif type == "DoRA":
109 | default = os.path.join(models_path, "Lora/_DoRA") # "models/Lora/_DoRA"
110 | folder = os.path.relpath(default, base)
111 | elif type == "VAE":
112 | if cmd_opts.vae_dir:
113 | default = cmd_opts.vae_dir # "models/VAE"
114 | else:
115 | default = os.path.join(models_path, "VAE")
116 | folder = os.path.relpath(default, base)
117 | elif type == "Controlnet":
118 | folder = "ControlNet"
119 | elif type == "Poses":
120 | folder = "OtherModels/Poses"
121 | elif type == "Upscaler":
122 | folder = "OtherModels/Upscaler"
123 | elif type == "MotionModule":
124 | folder = "OtherModels/MotionModule"
125 | elif type == "Wildcards":
126 | folder = "OtherModels/Wildcards"
127 | elif type == "Workflows":
128 | folder = "OtherModels/Workflows"
129 | elif type == "Detection":
130 | folder = "OtherModels/Detection"
131 | elif type == "Other":
132 | folder = "OtherModels/Other"
133 |
134 | optFolder = folderSetting.get(type, "")
135 | if optFolder == "":
136 | pType = Path(base) / Path(folder)
137 | else:
138 | pType = Path(base) / Path(optFolder)
139 | return pType
140 |
141 | def basemodel_path(baseModel: str) -> Path:
142 | basemodelPath = ""
143 | if not 'SD 1' in baseModel:
144 | basemodelPath = '_' + baseModel.replace(' ', '_').replace('.', '_')
145 | return Path(basemodelPath)
146 |
147 | def basemodel_path_all(baseModel: str) -> Path:
148 | basemodelPath = baseModel.replace(' ', '_').replace('.', '_')
149 | return Path(basemodelPath)
150 |
151 | def generate_model_save_path2(type, modelName: str = "", baseModel: str = "", nsfw: bool = False, userName=None, mID=None, vID=None, versionName=None) -> Path:
152 | # TYPE, MODELNAME, BASEMODEL, NSFW, UPNAME, MODEL_ID, VERSION_ID
153 | subfolders = {
154 | # "TYPE": type_path(type).as_posix(),
155 | "BASEMODELbkCmpt": basemodel_path(baseModel).as_posix(),
156 | "BASEMODEL": basemodel_path_all(baseModel).as_posix(),
157 | "NSFW": "nsfw" if nsfw else None,
158 | "USERNAME": escaped_filename(userName) if userName is not None else None,
159 | "MODELNAME": escaped_filename(modelName),
160 | "MODELID": str(mID) if mID is not None else None,
161 | "VERSIONNAME": escaped_filename(versionName),
162 | "VERSIONID": str(vID) if vID is not None else None,
163 | }
164 | if not str.strip(opts.civsfz_save_subfolder):
165 | subTree = "_{{BASEMODEL}}/.{{NSFW}}/{{MODELNAME}}"
166 | else:
167 | subTree = str.strip(opts.civsfz_save_subfolder)
168 | subTreeList = subTree.split("/")
169 | newTreeList = []
170 | for i, sub in enumerate(subTreeList):
171 | if sub:
172 | subKeys = re.findall("(\{\{(.+?)\}\})", sub)
173 | newSub = ""
174 | replaceSub = sub
175 | for subKey in subKeys:
176 | if subKey[1] is not None:
177 | if subKey[1] in subfolders:
178 | folder = subfolders[subKey[1]]
179 | if folder is not None:
180 | replaceSub = re.sub(
181 | "\{\{" + subKey[1] + "\}\}", folder, replaceSub)
182 | else:
183 | replaceSub = re.sub(
184 | "\{\{" + subKey[1] + "\}\}", "", replaceSub)
185 | else:
186 | print_ly(f'"{subKey[1]}" is not defined')
187 | replaceSub = "ERROR"
188 | newSub += replaceSub if replaceSub is not None else ""
189 |
190 | if newSub != "":
191 | newTreeList.append(newSub)
192 | else:
193 | if i != 0:
194 | print_lc(f"Empty subfolder:{i}")
195 | modelPath = type_path(type).joinpath(
196 | "/".join(newTreeList))
197 | return modelPath
198 |
199 | def filename_normalization(filename) -> str:
200 | if filename:
201 | filename = re.sub(r"__+", "_", filename)
202 | return filename
203 |
204 |
205 | def save_text_file(folder, filename, trained_words, description:str=""):
206 | filename = filename_normalization(filename)
207 | makedirs(folder)
208 | # filepath = os.path.join(folder, filename.replace(".ckpt",".txt")\
209 | # .replace(".safetensors",".txt")\
210 | # .replace(".pt",".txt")\
211 | # .replace(".yaml",".txt")\
212 | # .replace(".zip",".txt")\
213 | # )
214 | filepath = Path(folder) / Path(filename)
215 | filepath = filepath.with_suffix(".txt")
216 | overwrite = opts.civsfz_overwrite_metadata_file
217 | if overwrite:
218 | print_n(f"Overwrite allowed in settings")
219 | else:
220 | print_n(f"Overwrite not allowed in settings")
221 | if not filepath.exists() or overwrite:
222 | with open(filepath, 'w', encoding='UTF-8') as f:
223 | f.write(trained_words)
224 | print_n(f"Save {filepath.name}")
225 | else:
226 | print_n(f"File exists, so dosen't save {filepath.name}")
227 |
228 | # Save trigger words as metadata
229 | filepath = filepath.with_suffix(".json")
230 | metadata = {}
231 | try:
232 | if filepath.exists():
233 | with open(filepath, "r", encoding="utf8") as f:
234 | metadata = json.load(f)
235 | except Exception as e:
236 | print_ly(f"reading metadata from {filepath}:{e}" )
237 | # print_lc(f"{metadata=}")
238 | metadata["description"] = description
239 | metadata["activation text"] = re.sub(r"<.+?>", "", trained_words) # delete ""
240 | if not filepath.exists() or overwrite:
241 | try:
242 | with open(filepath, "w", encoding="UTF-8") as f:
243 | json.dump(metadata, f)
244 | print_n(f"Save {filepath.name}")
245 | except Exception as e:
246 | print_ly(f"Writing metadata to {filepath}:{e}")
247 | else:
248 | print_n(f"File exists, so dosen't save {filepath.name}")
249 | return "Save text"
250 |
251 |
252 | def makedirs(folder):
253 | if not os.path.exists(folder):
254 | os.makedirs(folder)
255 | print_lc(f'Make folder: {folder}')
256 |
257 | def isExistFile(folder, file):
258 | file = filename_normalization(file)
259 | isExist = False
260 | if folder != "" and folder is not None:
261 | path = os.path.join(folder, file)
262 | isExist = os.path.exists(path)
263 | return isExist
264 |
265 |
266 | def saveImageFiles(folder, versionName, html, content_type, versionInfo):
267 | html = versionInfo['html0']
268 | makedirs(folder)
269 | img_urls = re.findall(r'src=[\'"]?([^\'" >]+)', html)
270 | basename = os.path.splitext(versionName)[0] # remove extension
271 | basename = filename_normalization(basename)
272 | preview_url = versionInfo["modelVersions"][0]["images"][0]["url"]
273 | preview_url = urllib.parse.quote(preview_url, safe=':/=')
274 | if 'images' in versionInfo:
275 | for img in versionInfo['images']:
276 | if img['type'] == 'image':
277 | preview_url = img['url']
278 | preview_url = urllib.parse.quote(preview_url, safe=':/=')
279 | break
280 | with requests.Session() as session:
281 | HTML = html
282 | for i, img_url in enumerate(img_urls):
283 | isVideo = False
284 | filename = ""
285 | filenamethumb = ""
286 | for img in versionInfo["modelVersions"][0]["images"]:
287 | if img['url'] == img_url:
288 | if img['type'] == 'video':
289 | isVideo = True
290 | # print(Fore.LIGHTYELLOW_EX + f'URL: {img_url}'+ Style.RESET_ALL)
291 | if isVideo:
292 | if content_type == "TextualInversion":
293 | filename = f'{basename}_{i}.preview.webm'
294 | filenamethumb = f'{basename}.preview.webm'
295 | else:
296 | filename = f'{basename}_{i}.webm'
297 | filenamethumb = f'{basename}.webm'
298 | else:
299 | if content_type == "TextualInversion":
300 | filename = f'{basename}_{i}.preview.png'
301 | filenamethumb = f'{basename}.preview.png'
302 | else:
303 | filename = f'{basename}_{i}.png'
304 | filenamethumb = f'{basename}.png'
305 |
306 | HTML = HTML.replace(img_url, f'"{filename}"')
307 | url_parse = urllib.parse.urlparse(img_url)
308 | if url_parse.scheme:
309 | # img_url.replace("https", "http").replace("=","%3D")
310 | img_url = urllib.parse.quote(img_url, safe=':/=')
311 | try:
312 | response = session.get(img_url, timeout=read_timeout())
313 | with open(os.path.join(folder, filename), 'wb') as f:
314 | f.write(response.content)
315 | if img_url == preview_url:
316 | shutil.copy2(os.path.join(folder, filename),
317 | os.path.join(folder, filenamethumb))
318 | print_n(f"Save {filename}")
319 | # with urllib.request.urlretrieve(img_url, os.path.join(model_folder, filename)) as dl:
320 | except requests.exceptions.Timeout as e:
321 | print_ly(f'Error: {e}')
322 | print_ly(f'URL: {img_url}')
323 | # return "Err: Save infos"
324 | except requests.exceptions.RequestException as e:
325 | print_ly(f'Error: {e}')
326 | print_ly(f'URL: {img_url}')
327 | filepath = os.path.join(folder, f'{basename}.html')
328 | with open(filepath, 'wb') as f:
329 | f.write(HTML.encode('utf8'))
330 | print_n(f"Save {basename}.html")
331 | # Save json_info
332 | filepath = os.path.join(folder, f'{basename}.civitai.json')
333 | with open(filepath, mode="w", encoding="utf-8") as f:
334 | json.dump(versionInfo, f, indent=2, ensure_ascii=False)
335 | print_n(f"Save {basename}.civitai.json")
336 | return "Save infos"
337 |
338 | def removeFile(file):
339 | if send2trash_installed:
340 | try:
341 | send2trash(file.replace('/','\\'))
342 | except Exception as e:
343 | print_ly('Error: Fail to move file to trash')
344 | else:
345 | print_lc('Move file to trash')
346 | else:
347 | print_lc('File is not deleted. send2trash module is missing.')
348 | return
349 |
350 | def open_folder(f):
351 | '''
352 | [reference](https://github.com/AUTOMATIC1111/stable-diffusion-webui/blob/5ef669de080814067961f28357256e8fe27544f4/modules/ui_common.py#L109)
353 | '''
354 | if not f:
355 | return
356 | count = 0
357 | while not os.path.isdir(f):
358 | count += 1
359 | print_lc(f'Not found "{f}"')
360 | newf = os.path.abspath(os.path.join(f, os.pardir))
361 | if newf == f:
362 | break
363 | if count >5:
364 | print_lc(f'Not found the folder')
365 | return
366 | f = newf
367 | path = os.path.normpath(f)
368 | if os.path.isdir(path):
369 | if platform.system() == "Windows":
370 | # sp.Popen(rf'explorer /select,"{path}"')
371 | # sp.run(["explorer", path])
372 | os.startfile(path, operation="explore")
373 | elif platform.system() == "Darwin":
374 | # sp.run(["open", path])
375 | sp.Popen(["open", path])
376 | elif "microsoft-standard-WSL2" in platform.uname().release:
377 | # sp.run(["wsl-open", path])
378 | sp.Popen(["explorer.exe", sp.check_output(["wslpath", "-w", path])])
379 | else:
380 | # sp.run(["xdg-open", path])
381 | sp.Popen(["xdg-open", path])
382 | else:
383 | print_lc(f'Not found "{path}"')
384 |
385 | class History():
386 | def __init__(self, path=None):
387 | self._path = Path.joinpath(
388 | extensionFolder(), Path("../history.json"))
389 | if path != None:
390 | self._path = path
391 | self._history: deque = self.load()
392 | def load(self) -> list[dict]:
393 | try:
394 | with open(self._path, 'r', encoding="utf-8") as f:
395 | ret = json.load(f)
396 | except:
397 | ret = []
398 | return deque(ret,maxlen=30)
399 | def save(self):
400 | try:
401 | with open(self._path, 'w', encoding="utf-8") as f:
402 | json.dump(list(self._history), f, indent=4, ensure_ascii=False)
403 | except Exception as e:
404 | #print_lc(e)
405 | pass
406 | def len(self) -> int:
407 | return len(self._history) if self._history is not None else None
408 | def getAsChoices(self):
409 | ret = [
410 | json.dumps(h, ensure_ascii=False) for h in self._history]
411 | return ret
412 |
413 | class KeywordHistory(History):
414 |
415 | def __init__(self, fileName="keyword_history.json"):
416 | super().__init__(
417 | Path.joinpath(extensionFolder(), Path(f"../{fileName}"))
418 | )
419 |
420 | def add(self, type="Keyword", word=None):
421 | if type == "No" or word == "" or word == None:
422 | return
423 | if type == "User name" and word in FavoriteCreators.getAsList():
424 | return
425 | d = {"type": type, "word": word}
426 | try:
427 | self._history.remove(d)
428 | except:
429 | pass
430 | self._history.appendleft(d)
431 | while self.len(type) > opts.civsfz_length_of_search_history:
432 | self.pop(type)
433 | self.save()
434 |
435 | def getAsChoices(self, type="Keyword"):
436 | # ret = [f'{w["word"]}{self._delimiter}{w["type"]}' for w in self._history]
437 | ret = [f'{w["word"]}' for w in self._history if w["type"] == type]
438 | # Add favorite users
439 | favUsers = []
440 | if type == "User name":
441 | favUsers = [f"⭐️{s.strip()}" for s in FavoriteCreators.getAsList()]
442 | return ret + favUsers
443 |
444 | def len(self, type="Keyword"):
445 | return len([ w for w in self._history if w.get("type") == type])
446 |
447 | def pop(self, type="Keyword"):
448 | j = (
449 | len(self._history)
450 | - next(i for i, v in enumerate(reversed(self._history)) if v.get("type") == type)
451 | - 1
452 | )
453 | l = list(self._history)
454 | l.pop(j)
455 | self._history = deque(l)
456 |
457 |
458 | class SearchHistory(History):
459 | def __init__(self):
460 | super().__init__(Path.joinpath(
461 | extensionFolder(), Path("../search_history.json")))
462 | self._delimiter = "_._"
463 | def add(self, type, word):
464 | if type == "No" or word == "" or word == None:
465 | return
466 | if word in FavoriteCreators.getAsList():
467 | return
468 | d = { "type" : type,
469 | "word": word }
470 | try:
471 | self._history.remove(d)
472 | except:
473 | pass
474 | self._history.appendleft(d)
475 | while self.len() > opts.civsfz_length_of_search_history:
476 | self._history.pop()
477 | self.save()
478 | def getAsChoices(self):
479 | ret = [f'{w["word"]}{self._delimiter}{w["type"]}' for w in self._history]
480 | # Add favorite users
481 | favUsers = [
482 | f"{s.strip()}{self._delimiter}User name{self._delimiter}⭐️"
483 | for s in FavoriteCreators.getAsList()
484 | ]
485 | return ret + favUsers
486 | def getDelimiter(self) -> str:
487 | return self._delimiter
488 |
489 | class ConditionsHistory(History):
490 | def __init__(self):
491 | super().__init__(Path.joinpath(
492 | extensionFolder(), Path("../conditions_history.json")))
493 | self._delimiter = "_._"
494 | def add(self, sort, period, baseModels, nsfw ):
495 | d = {
496 | "sort": sort,
497 | "period": period,
498 | "baseModels": baseModels,
499 | "nsfw": nsfw
500 | }
501 | try:
502 | self._history.remove(d)
503 | except:
504 | pass
505 | self._history.appendleft(d)
506 | while self.len() > opts.civsfz_length_of_conditions_history:
507 | self._history.pop()
508 | self.save()
509 | def getAsChoices(self):
510 | ret = [self._delimiter.join(
511 | [ str(v) if k != 'baseModels' else json.dumps(v, ensure_ascii=False) for k, v in h.items()]
512 | ) for h in self._history]
513 | return ret
514 | def getDelimiter(self) -> str:
515 | return self._delimiter
516 | HistoryKwd = KeywordHistory()
517 | HistoryS = SearchHistory() # deprecated
518 | HistoryC = ConditionsHistory()
519 |
520 | class UserInfo:
521 | def __init__(self, path=None):
522 | self._path = Path.joinpath(extensionFolder(), Path("../users.json"))
523 | if path != None:
524 | self._path = path
525 | self._users: list = self.load()
526 |
527 | def add(self, name: str) -> bool:
528 | name = name.strip()
529 | if name == "":
530 | return False
531 | self.remove(name)
532 | self._users.append(name)
533 | self.save()
534 | return True
535 |
536 | def remove(self, name: str) -> bool:
537 | name = name.strip()
538 | if name == "":
539 | return False
540 | self._users = [u for u in self._users if u != name]
541 | self.save()
542 | return True
543 |
544 | def load(self) -> list:
545 | try:
546 | with open(self._path, "r", encoding="utf-8") as f:
547 | lines = f.readlines()
548 | line = ", ".join(lines)
549 | ret = [s.strip() for s in line.split(",") if s.strip()]
550 | except Exception as e:
551 | # print_lc(f"{e}")
552 | ret = []
553 | return ret
554 |
555 | def save(self):
556 | try:
557 | with open(self._path, "w", encoding="utf-8") as f:
558 | l = len(self._users)
559 | n = 3
560 | for i in range(0,int(l),n):
561 | f.write(", ".join(self._users[i : i + n]))
562 | f.write("\n")
563 | except Exception as e:
564 | # print_lc(e)
565 | pass
566 | def getAsList(self) -> list[str]:
567 | return self._users
568 | def getAsText(self) -> str:
569 | return ", ".join(self._users)
570 |
571 | class FavoriteUsers(UserInfo):
572 | def __init__(self):
573 | super().__init__(
574 | Path.joinpath(extensionFolder(), Path("../favoriteUsers.txt"))
575 | )
576 | if hasattr(opts, "civsfz_favorite_creators"): # for backward compatibility
577 | users = [s.strip() for s in opts.civsfz_favorite_creators.split(",") if s.strip()]
578 | for u in users:
579 | self.add(u)
580 |
581 | class BanUsers(UserInfo):
582 | def __init__(self):
583 | super().__init__(
584 | Path.joinpath(extensionFolder(), Path("../bannedUsers.txt")))
585 | if hasattr(opts, "civsfz_ban_creators"): # for backward compatibility
586 | users = [
587 | s.strip() for s in opts.civsfz_ban_creators.split(",") if s.strip()
588 | ]
589 | for u in users:
590 | self.add(u)
591 | FavoriteCreators = FavoriteUsers()
592 | BanCreators = BanUsers()
593 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU AFFERO GENERAL PUBLIC LICENSE
2 | Version 3, 19 November 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU Affero General Public License is a free, copyleft license for
11 | software and other kinds of works, specifically designed to ensure
12 | cooperation with the community in the case of network server software.
13 |
14 | The licenses for most software and other practical works are designed
15 | to take away your freedom to share and change the works. By contrast,
16 | our General Public Licenses are intended to guarantee your freedom to
17 | share and change all versions of a program--to make sure it remains free
18 | software for all its users.
19 |
20 | When we speak of free software, we are referring to freedom, not
21 | price. Our General Public Licenses are designed to make sure that you
22 | have the freedom to distribute copies of free software (and charge for
23 | them if you wish), that you receive source code or can get it if you
24 | want it, that you can change the software or use pieces of it in new
25 | free programs, and that you know you can do these things.
26 |
27 | Developers that use our General Public Licenses protect your rights
28 | with two steps: (1) assert copyright on the software, and (2) offer
29 | you this License which gives you legal permission to copy, distribute
30 | and/or modify the software.
31 |
32 | A secondary benefit of defending all users' freedom is that
33 | improvements made in alternate versions of the program, if they
34 | receive widespread use, become available for other developers to
35 | incorporate. Many developers of free software are heartened and
36 | encouraged by the resulting cooperation. However, in the case of
37 | software used on network servers, this result may fail to come about.
38 | The GNU General Public License permits making a modified version and
39 | letting the public access it on a server without ever releasing its
40 | source code to the public.
41 |
42 | The GNU Affero General Public License is designed specifically to
43 | ensure that, in such cases, the modified source code becomes available
44 | to the community. It requires the operator of a network server to
45 | provide the source code of the modified version running there to the
46 | users of that server. Therefore, public use of a modified version, on
47 | a publicly accessible server, gives the public access to the source
48 | code of the modified version.
49 |
50 | An older license, called the Affero General Public License and
51 | published by Affero, was designed to accomplish similar goals. This is
52 | a different license, not a version of the Affero GPL, but Affero has
53 | released a new version of the Affero GPL which permits relicensing under
54 | this license.
55 |
56 | The precise terms and conditions for copying, distribution and
57 | modification follow.
58 |
59 | TERMS AND CONDITIONS
60 |
61 | 0. Definitions.
62 |
63 | "This License" refers to version 3 of the GNU Affero General Public License.
64 |
65 | "Copyright" also means copyright-like laws that apply to other kinds of
66 | works, such as semiconductor masks.
67 |
68 | "The Program" refers to any copyrightable work licensed under this
69 | License. Each licensee is addressed as "you". "Licensees" and
70 | "recipients" may be individuals or organizations.
71 |
72 | To "modify" a work means to copy from or adapt all or part of the work
73 | in a fashion requiring copyright permission, other than the making of an
74 | exact copy. The resulting work is called a "modified version" of the
75 | earlier work or a work "based on" the earlier work.
76 |
77 | A "covered work" means either the unmodified Program or a work based
78 | on the Program.
79 |
80 | To "propagate" a work means to do anything with it that, without
81 | permission, would make you directly or secondarily liable for
82 | infringement under applicable copyright law, except executing it on a
83 | computer or modifying a private copy. Propagation includes copying,
84 | distribution (with or without modification), making available to the
85 | public, and in some countries other activities as well.
86 |
87 | To "convey" a work means any kind of propagation that enables other
88 | parties to make or receive copies. Mere interaction with a user through
89 | a computer network, with no transfer of a copy, is not conveying.
90 |
91 | An interactive user interface displays "Appropriate Legal Notices"
92 | to the extent that it includes a convenient and prominently visible
93 | feature that (1) displays an appropriate copyright notice, and (2)
94 | tells the user that there is no warranty for the work (except to the
95 | extent that warranties are provided), that licensees may convey the
96 | work under this License, and how to view a copy of this License. If
97 | the interface presents a list of user commands or options, such as a
98 | menu, a prominent item in the list meets this criterion.
99 |
100 | 1. Source Code.
101 |
102 | The "source code" for a work means the preferred form of the work
103 | for making modifications to it. "Object code" means any non-source
104 | form of a work.
105 |
106 | A "Standard Interface" means an interface that either is an official
107 | standard defined by a recognized standards body, or, in the case of
108 | interfaces specified for a particular programming language, one that
109 | is widely used among developers working in that language.
110 |
111 | The "System Libraries" of an executable work include anything, other
112 | than the work as a whole, that (a) is included in the normal form of
113 | packaging a Major Component, but which is not part of that Major
114 | Component, and (b) serves only to enable use of the work with that
115 | Major Component, or to implement a Standard Interface for which an
116 | implementation is available to the public in source code form. A
117 | "Major Component", in this context, means a major essential component
118 | (kernel, window system, and so on) of the specific operating system
119 | (if any) on which the executable work runs, or a compiler used to
120 | produce the work, or an object code interpreter used to run it.
121 |
122 | The "Corresponding Source" for a work in object code form means all
123 | the source code needed to generate, install, and (for an executable
124 | work) run the object code and to modify the work, including scripts to
125 | control those activities. However, it does not include the work's
126 | System Libraries, or general-purpose tools or generally available free
127 | programs which are used unmodified in performing those activities but
128 | which are not part of the work. For example, Corresponding Source
129 | includes interface definition files associated with source files for
130 | the work, and the source code for shared libraries and dynamically
131 | linked subprograms that the work is specifically designed to require,
132 | such as by intimate data communication or control flow between those
133 | subprograms and other parts of the work.
134 |
135 | The Corresponding Source need not include anything that users
136 | can regenerate automatically from other parts of the Corresponding
137 | Source.
138 |
139 | The Corresponding Source for a work in source code form is that
140 | same work.
141 |
142 | 2. Basic Permissions.
143 |
144 | All rights granted under this License are granted for the term of
145 | copyright on the Program, and are irrevocable provided the stated
146 | conditions are met. This License explicitly affirms your unlimited
147 | permission to run the unmodified Program. The output from running a
148 | covered work is covered by this License only if the output, given its
149 | content, constitutes a covered work. This License acknowledges your
150 | rights of fair use or other equivalent, as provided by copyright law.
151 |
152 | You may make, run and propagate covered works that you do not
153 | convey, without conditions so long as your license otherwise remains
154 | in force. You may convey covered works to others for the sole purpose
155 | of having them make modifications exclusively for you, or provide you
156 | with facilities for running those works, provided that you comply with
157 | the terms of this License in conveying all material for which you do
158 | not control copyright. Those thus making or running the covered works
159 | for you must do so exclusively on your behalf, under your direction
160 | and control, on terms that prohibit them from making any copies of
161 | your copyrighted material outside their relationship with you.
162 |
163 | Conveying under any other circumstances is permitted solely under
164 | the conditions stated below. Sublicensing is not allowed; section 10
165 | makes it unnecessary.
166 |
167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 |
169 | No covered work shall be deemed part of an effective technological
170 | measure under any applicable law fulfilling obligations under article
171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 | similar laws prohibiting or restricting circumvention of such
173 | measures.
174 |
175 | When you convey a covered work, you waive any legal power to forbid
176 | circumvention of technological measures to the extent such circumvention
177 | is effected by exercising rights under this License with respect to
178 | the covered work, and you disclaim any intention to limit operation or
179 | modification of the work as a means of enforcing, against the work's
180 | users, your or third parties' legal rights to forbid circumvention of
181 | technological measures.
182 |
183 | 4. Conveying Verbatim Copies.
184 |
185 | You may convey verbatim copies of the Program's source code as you
186 | receive it, in any medium, provided that you conspicuously and
187 | appropriately publish on each copy an appropriate copyright notice;
188 | keep intact all notices stating that this License and any
189 | non-permissive terms added in accord with section 7 apply to the code;
190 | keep intact all notices of the absence of any warranty; and give all
191 | recipients a copy of this License along with the Program.
192 |
193 | You may charge any price or no price for each copy that you convey,
194 | and you may offer support or warranty protection for a fee.
195 |
196 | 5. Conveying Modified Source Versions.
197 |
198 | You may convey a work based on the Program, or the modifications to
199 | produce it from the Program, in the form of source code under the
200 | terms of section 4, provided that you also meet all of these conditions:
201 |
202 | a) The work must carry prominent notices stating that you modified
203 | it, and giving a relevant date.
204 |
205 | b) The work must carry prominent notices stating that it is
206 | released under this License and any conditions added under section
207 | 7. This requirement modifies the requirement in section 4 to
208 | "keep intact all notices".
209 |
210 | c) You must license the entire work, as a whole, under this
211 | License to anyone who comes into possession of a copy. This
212 | License will therefore apply, along with any applicable section 7
213 | additional terms, to the whole of the work, and all its parts,
214 | regardless of how they are packaged. This License gives no
215 | permission to license the work in any other way, but it does not
216 | invalidate such permission if you have separately received it.
217 |
218 | d) If the work has interactive user interfaces, each must display
219 | Appropriate Legal Notices; however, if the Program has interactive
220 | interfaces that do not display Appropriate Legal Notices, your
221 | work need not make them do so.
222 |
223 | A compilation of a covered work with other separate and independent
224 | works, which are not by their nature extensions of the covered work,
225 | and which are not combined with it such as to form a larger program,
226 | in or on a volume of a storage or distribution medium, is called an
227 | "aggregate" if the compilation and its resulting copyright are not
228 | used to limit the access or legal rights of the compilation's users
229 | beyond what the individual works permit. Inclusion of a covered work
230 | in an aggregate does not cause this License to apply to the other
231 | parts of the aggregate.
232 |
233 | 6. Conveying Non-Source Forms.
234 |
235 | You may convey a covered work in object code form under the terms
236 | of sections 4 and 5, provided that you also convey the
237 | machine-readable Corresponding Source under the terms of this License,
238 | in one of these ways:
239 |
240 | a) Convey the object code in, or embodied in, a physical product
241 | (including a physical distribution medium), accompanied by the
242 | Corresponding Source fixed on a durable physical medium
243 | customarily used for software interchange.
244 |
245 | b) Convey the object code in, or embodied in, a physical product
246 | (including a physical distribution medium), accompanied by a
247 | written offer, valid for at least three years and valid for as
248 | long as you offer spare parts or customer support for that product
249 | model, to give anyone who possesses the object code either (1) a
250 | copy of the Corresponding Source for all the software in the
251 | product that is covered by this License, on a durable physical
252 | medium customarily used for software interchange, for a price no
253 | more than your reasonable cost of physically performing this
254 | conveying of source, or (2) access to copy the
255 | Corresponding Source from a network server at no charge.
256 |
257 | c) Convey individual copies of the object code with a copy of the
258 | written offer to provide the Corresponding Source. This
259 | alternative is allowed only occasionally and noncommercially, and
260 | only if you received the object code with such an offer, in accord
261 | with subsection 6b.
262 |
263 | d) Convey the object code by offering access from a designated
264 | place (gratis or for a charge), and offer equivalent access to the
265 | Corresponding Source in the same way through the same place at no
266 | further charge. You need not require recipients to copy the
267 | Corresponding Source along with the object code. If the place to
268 | copy the object code is a network server, the Corresponding Source
269 | may be on a different server (operated by you or a third party)
270 | that supports equivalent copying facilities, provided you maintain
271 | clear directions next to the object code saying where to find the
272 | Corresponding Source. Regardless of what server hosts the
273 | Corresponding Source, you remain obligated to ensure that it is
274 | available for as long as needed to satisfy these requirements.
275 |
276 | e) Convey the object code using peer-to-peer transmission, provided
277 | you inform other peers where the object code and Corresponding
278 | Source of the work are being offered to the general public at no
279 | charge under subsection 6d.
280 |
281 | A separable portion of the object code, whose source code is excluded
282 | from the Corresponding Source as a System Library, need not be
283 | included in conveying the object code work.
284 |
285 | A "User Product" is either (1) a "consumer product", which means any
286 | tangible personal property which is normally used for personal, family,
287 | or household purposes, or (2) anything designed or sold for incorporation
288 | into a dwelling. In determining whether a product is a consumer product,
289 | doubtful cases shall be resolved in favor of coverage. For a particular
290 | product received by a particular user, "normally used" refers to a
291 | typical or common use of that class of product, regardless of the status
292 | of the particular user or of the way in which the particular user
293 | actually uses, or expects or is expected to use, the product. A product
294 | is a consumer product regardless of whether the product has substantial
295 | commercial, industrial or non-consumer uses, unless such uses represent
296 | the only significant mode of use of the product.
297 |
298 | "Installation Information" for a User Product means any methods,
299 | procedures, authorization keys, or other information required to install
300 | and execute modified versions of a covered work in that User Product from
301 | a modified version of its Corresponding Source. The information must
302 | suffice to ensure that the continued functioning of the modified object
303 | code is in no case prevented or interfered with solely because
304 | modification has been made.
305 |
306 | If you convey an object code work under this section in, or with, or
307 | specifically for use in, a User Product, and the conveying occurs as
308 | part of a transaction in which the right of possession and use of the
309 | User Product is transferred to the recipient in perpetuity or for a
310 | fixed term (regardless of how the transaction is characterized), the
311 | Corresponding Source conveyed under this section must be accompanied
312 | by the Installation Information. But this requirement does not apply
313 | if neither you nor any third party retains the ability to install
314 | modified object code on the User Product (for example, the work has
315 | been installed in ROM).
316 |
317 | The requirement to provide Installation Information does not include a
318 | requirement to continue to provide support service, warranty, or updates
319 | for a work that has been modified or installed by the recipient, or for
320 | the User Product in which it has been modified or installed. Access to a
321 | network may be denied when the modification itself materially and
322 | adversely affects the operation of the network or violates the rules and
323 | protocols for communication across the network.
324 |
325 | Corresponding Source conveyed, and Installation Information provided,
326 | in accord with this section must be in a format that is publicly
327 | documented (and with an implementation available to the public in
328 | source code form), and must require no special password or key for
329 | unpacking, reading or copying.
330 |
331 | 7. Additional Terms.
332 |
333 | "Additional permissions" are terms that supplement the terms of this
334 | License by making exceptions from one or more of its conditions.
335 | Additional permissions that are applicable to the entire Program shall
336 | be treated as though they were included in this License, to the extent
337 | that they are valid under applicable law. If additional permissions
338 | apply only to part of the Program, that part may be used separately
339 | under those permissions, but the entire Program remains governed by
340 | this License without regard to the additional permissions.
341 |
342 | When you convey a copy of a covered work, you may at your option
343 | remove any additional permissions from that copy, or from any part of
344 | it. (Additional permissions may be written to require their own
345 | removal in certain cases when you modify the work.) You may place
346 | additional permissions on material, added by you to a covered work,
347 | for which you have or can give appropriate copyright permission.
348 |
349 | Notwithstanding any other provision of this License, for material you
350 | add to a covered work, you may (if authorized by the copyright holders of
351 | that material) supplement the terms of this License with terms:
352 |
353 | a) Disclaiming warranty or limiting liability differently from the
354 | terms of sections 15 and 16 of this License; or
355 |
356 | b) Requiring preservation of specified reasonable legal notices or
357 | author attributions in that material or in the Appropriate Legal
358 | Notices displayed by works containing it; or
359 |
360 | c) Prohibiting misrepresentation of the origin of that material, or
361 | requiring that modified versions of such material be marked in
362 | reasonable ways as different from the original version; or
363 |
364 | d) Limiting the use for publicity purposes of names of licensors or
365 | authors of the material; or
366 |
367 | e) Declining to grant rights under trademark law for use of some
368 | trade names, trademarks, or service marks; or
369 |
370 | f) Requiring indemnification of licensors and authors of that
371 | material by anyone who conveys the material (or modified versions of
372 | it) with contractual assumptions of liability to the recipient, for
373 | any liability that these contractual assumptions directly impose on
374 | those licensors and authors.
375 |
376 | All other non-permissive additional terms are considered "further
377 | restrictions" within the meaning of section 10. If the Program as you
378 | received it, or any part of it, contains a notice stating that it is
379 | governed by this License along with a term that is a further
380 | restriction, you may remove that term. If a license document contains
381 | a further restriction but permits relicensing or conveying under this
382 | License, you may add to a covered work material governed by the terms
383 | of that license document, provided that the further restriction does
384 | not survive such relicensing or conveying.
385 |
386 | If you add terms to a covered work in accord with this section, you
387 | must place, in the relevant source files, a statement of the
388 | additional terms that apply to those files, or a notice indicating
389 | where to find the applicable terms.
390 |
391 | Additional terms, permissive or non-permissive, may be stated in the
392 | form of a separately written license, or stated as exceptions;
393 | the above requirements apply either way.
394 |
395 | 8. Termination.
396 |
397 | You may not propagate or modify a covered work except as expressly
398 | provided under this License. Any attempt otherwise to propagate or
399 | modify it is void, and will automatically terminate your rights under
400 | this License (including any patent licenses granted under the third
401 | paragraph of section 11).
402 |
403 | However, if you cease all violation of this License, then your
404 | license from a particular copyright holder is reinstated (a)
405 | provisionally, unless and until the copyright holder explicitly and
406 | finally terminates your license, and (b) permanently, if the copyright
407 | holder fails to notify you of the violation by some reasonable means
408 | prior to 60 days after the cessation.
409 |
410 | Moreover, your license from a particular copyright holder is
411 | reinstated permanently if the copyright holder notifies you of the
412 | violation by some reasonable means, this is the first time you have
413 | received notice of violation of this License (for any work) from that
414 | copyright holder, and you cure the violation prior to 30 days after
415 | your receipt of the notice.
416 |
417 | Termination of your rights under this section does not terminate the
418 | licenses of parties who have received copies or rights from you under
419 | this License. If your rights have been terminated and not permanently
420 | reinstated, you do not qualify to receive new licenses for the same
421 | material under section 10.
422 |
423 | 9. Acceptance Not Required for Having Copies.
424 |
425 | You are not required to accept this License in order to receive or
426 | run a copy of the Program. Ancillary propagation of a covered work
427 | occurring solely as a consequence of using peer-to-peer transmission
428 | to receive a copy likewise does not require acceptance. However,
429 | nothing other than this License grants you permission to propagate or
430 | modify any covered work. These actions infringe copyright if you do
431 | not accept this License. Therefore, by modifying or propagating a
432 | covered work, you indicate your acceptance of this License to do so.
433 |
434 | 10. Automatic Licensing of Downstream Recipients.
435 |
436 | Each time you convey a covered work, the recipient automatically
437 | receives a license from the original licensors, to run, modify and
438 | propagate that work, subject to this License. You are not responsible
439 | for enforcing compliance by third parties with this License.
440 |
441 | An "entity transaction" is a transaction transferring control of an
442 | organization, or substantially all assets of one, or subdividing an
443 | organization, or merging organizations. If propagation of a covered
444 | work results from an entity transaction, each party to that
445 | transaction who receives a copy of the work also receives whatever
446 | licenses to the work the party's predecessor in interest had or could
447 | give under the previous paragraph, plus a right to possession of the
448 | Corresponding Source of the work from the predecessor in interest, if
449 | the predecessor has it or can get it with reasonable efforts.
450 |
451 | You may not impose any further restrictions on the exercise of the
452 | rights granted or affirmed under this License. For example, you may
453 | not impose a license fee, royalty, or other charge for exercise of
454 | rights granted under this License, and you may not initiate litigation
455 | (including a cross-claim or counterclaim in a lawsuit) alleging that
456 | any patent claim is infringed by making, using, selling, offering for
457 | sale, or importing the Program or any portion of it.
458 |
459 | 11. Patents.
460 |
461 | A "contributor" is a copyright holder who authorizes use under this
462 | License of the Program or a work on which the Program is based. The
463 | work thus licensed is called the contributor's "contributor version".
464 |
465 | A contributor's "essential patent claims" are all patent claims
466 | owned or controlled by the contributor, whether already acquired or
467 | hereafter acquired, that would be infringed by some manner, permitted
468 | by this License, of making, using, or selling its contributor version,
469 | but do not include claims that would be infringed only as a
470 | consequence of further modification of the contributor version. For
471 | purposes of this definition, "control" includes the right to grant
472 | patent sublicenses in a manner consistent with the requirements of
473 | this License.
474 |
475 | Each contributor grants you a non-exclusive, worldwide, royalty-free
476 | patent license under the contributor's essential patent claims, to
477 | make, use, sell, offer for sale, import and otherwise run, modify and
478 | propagate the contents of its contributor version.
479 |
480 | In the following three paragraphs, a "patent license" is any express
481 | agreement or commitment, however denominated, not to enforce a patent
482 | (such as an express permission to practice a patent or covenant not to
483 | sue for patent infringement). To "grant" such a patent license to a
484 | party means to make such an agreement or commitment not to enforce a
485 | patent against the party.
486 |
487 | If you convey a covered work, knowingly relying on a patent license,
488 | and the Corresponding Source of the work is not available for anyone
489 | to copy, free of charge and under the terms of this License, through a
490 | publicly available network server or other readily accessible means,
491 | then you must either (1) cause the Corresponding Source to be so
492 | available, or (2) arrange to deprive yourself of the benefit of the
493 | patent license for this particular work, or (3) arrange, in a manner
494 | consistent with the requirements of this License, to extend the patent
495 | license to downstream recipients. "Knowingly relying" means you have
496 | actual knowledge that, but for the patent license, your conveying the
497 | covered work in a country, or your recipient's use of the covered work
498 | in a country, would infringe one or more identifiable patents in that
499 | country that you have reason to believe are valid.
500 |
501 | If, pursuant to or in connection with a single transaction or
502 | arrangement, you convey, or propagate by procuring conveyance of, a
503 | covered work, and grant a patent license to some of the parties
504 | receiving the covered work authorizing them to use, propagate, modify
505 | or convey a specific copy of the covered work, then the patent license
506 | you grant is automatically extended to all recipients of the covered
507 | work and works based on it.
508 |
509 | A patent license is "discriminatory" if it does not include within
510 | the scope of its coverage, prohibits the exercise of, or is
511 | conditioned on the non-exercise of one or more of the rights that are
512 | specifically granted under this License. You may not convey a covered
513 | work if you are a party to an arrangement with a third party that is
514 | in the business of distributing software, under which you make payment
515 | to the third party based on the extent of your activity of conveying
516 | the work, and under which the third party grants, to any of the
517 | parties who would receive the covered work from you, a discriminatory
518 | patent license (a) in connection with copies of the covered work
519 | conveyed by you (or copies made from those copies), or (b) primarily
520 | for and in connection with specific products or compilations that
521 | contain the covered work, unless you entered into that arrangement,
522 | or that patent license was granted, prior to 28 March 2007.
523 |
524 | Nothing in this License shall be construed as excluding or limiting
525 | any implied license or other defenses to infringement that may
526 | otherwise be available to you under applicable patent law.
527 |
528 | 12. No Surrender of Others' Freedom.
529 |
530 | If conditions are imposed on you (whether by court order, agreement or
531 | otherwise) that contradict the conditions of this License, they do not
532 | excuse you from the conditions of this License. If you cannot convey a
533 | covered work so as to satisfy simultaneously your obligations under this
534 | License and any other pertinent obligations, then as a consequence you may
535 | not convey it at all. For example, if you agree to terms that obligate you
536 | to collect a royalty for further conveying from those to whom you convey
537 | the Program, the only way you could satisfy both those terms and this
538 | License would be to refrain entirely from conveying the Program.
539 |
540 | 13. Remote Network Interaction; Use with the GNU General Public License.
541 |
542 | Notwithstanding any other provision of this License, if you modify the
543 | Program, your modified version must prominently offer all users
544 | interacting with it remotely through a computer network (if your version
545 | supports such interaction) an opportunity to receive the Corresponding
546 | Source of your version by providing access to the Corresponding Source
547 | from a network server at no charge, through some standard or customary
548 | means of facilitating copying of software. This Corresponding Source
549 | shall include the Corresponding Source for any work covered by version 3
550 | of the GNU General Public License that is incorporated pursuant to the
551 | following paragraph.
552 |
553 | Notwithstanding any other provision of this License, you have
554 | permission to link or combine any covered work with a work licensed
555 | under version 3 of the GNU General Public License into a single
556 | combined work, and to convey the resulting work. The terms of this
557 | License will continue to apply to the part which is the covered work,
558 | but the work with which it is combined will remain governed by version
559 | 3 of the GNU General Public License.
560 |
561 | 14. Revised Versions of this License.
562 |
563 | The Free Software Foundation may publish revised and/or new versions of
564 | the GNU Affero General Public License from time to time. Such new versions
565 | will be similar in spirit to the present version, but may differ in detail to
566 | address new problems or concerns.
567 |
568 | Each version is given a distinguishing version number. If the
569 | Program specifies that a certain numbered version of the GNU Affero General
570 | Public License "or any later version" applies to it, you have the
571 | option of following the terms and conditions either of that numbered
572 | version or of any later version published by the Free Software
573 | Foundation. If the Program does not specify a version number of the
574 | GNU Affero General Public License, you may choose any version ever published
575 | by the Free Software Foundation.
576 |
577 | If the Program specifies that a proxy can decide which future
578 | versions of the GNU Affero General Public License can be used, that proxy's
579 | public statement of acceptance of a version permanently authorizes you
580 | to choose that version for the Program.
581 |
582 | Later license versions may give you additional or different
583 | permissions. However, no additional obligations are imposed on any
584 | author or copyright holder as a result of your choosing to follow a
585 | later version.
586 |
587 | 15. Disclaimer of Warranty.
588 |
589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 |
598 | 16. Limitation of Liability.
599 |
600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 | SUCH DAMAGES.
609 |
610 | 17. Interpretation of Sections 15 and 16.
611 |
612 | If the disclaimer of warranty and limitation of liability provided
613 | above cannot be given local legal effect according to their terms,
614 | reviewing courts shall apply local law that most closely approximates
615 | an absolute waiver of all civil liability in connection with the
616 | Program, unless a warranty or assumption of liability accompanies a
617 | copy of the Program in return for a fee.
618 |
619 | END OF TERMS AND CONDITIONS
620 |
621 | How to Apply These Terms to Your New Programs
622 |
623 | If you develop a new program, and you want it to be of the greatest
624 | possible use to the public, the best way to achieve this is to make it
625 | free software which everyone can redistribute and change under these terms.
626 |
627 | To do so, attach the following notices to the program. It is safest
628 | to attach them to the start of each source file to most effectively
629 | state the exclusion of warranty; and each file should have at least
630 | the "copyright" line and a pointer to where the full notice is found.
631 |
632 |
633 | Copyright (C)
634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/scripts/civsfz_api.py:
--------------------------------------------------------------------------------
1 | import re
2 | import datetime
3 | from dateutil import tz
4 | import json
5 | import urllib.parse
6 | from pathlib import Path
7 | import requests
8 | # from requests_cache import CachedSession
9 | from colorama import Fore, Back, Style
10 | from scripts.civsfz_filemanage import (
11 | generate_model_save_path2,
12 | extensionFolder,
13 | FavoriteCreators,
14 | BanCreators,
15 | isExistFile,
16 | )
17 | from scripts.civsfz_color import dictBasemodelColors
18 | from scripts.civsfz_shared import opts, read_timeout, card_no_preview
19 | from jinja2 import Environment, FileSystemLoader
20 |
21 | print_ly = lambda x: print(Fore.LIGHTYELLOW_EX + "CivBrowser: " + x + Style.RESET_ALL )
22 | print_lc = lambda x: print(Fore.LIGHTCYAN_EX + "CivBrowser: " + x + Style.RESET_ALL )
23 | print_n = lambda x: print("CivBrowser: " + x )
24 |
25 | templatesPath = Path.joinpath(
26 | extensionFolder(), Path("../templates"))
27 | environment = Environment(
28 | loader=FileSystemLoader(templatesPath.resolve()),
29 | extensions=["jinja2.ext.loopcontrols"],
30 | )
31 |
32 | class Browser:
33 | session = None
34 |
35 | def __init__(self):
36 | if Browser.session is None:
37 | Browser.session = requests.Session()
38 | Browser.session.headers.update(
39 | {'User-Agent': r'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0'})
40 | #self.setAPIKey("")
41 | def __enter__(self):
42 | return Browser.session
43 | def __exit__(self):
44 | Browser.session.close()
45 | def newSession(self):
46 | Browser.session = requests.Session()
47 | def reConnect(self):
48 | Browser.session.close()
49 | self.newSession()
50 | def setAPIKey(self, api_key):
51 | if len(api_key) == 32:
52 | Browser.session.headers.update(
53 | {"Authorization": f"Bearer {api_key}"})
54 | print_lc("Apply API key")
55 |
56 |
57 | class ModelCardsPagination:
58 | def __init__(self, response:dict, types:list=None, sort:str=None, searchType:str=None, searchTerm:str=None, nsfw:bool=None, period:str=None, basemodels:list=None) -> None:
59 | self.types = types
60 | self.sort = sort
61 | self.searchType = searchType
62 | self.searchTerm = searchTerm
63 | self.nsfw = nsfw
64 | self.period = period
65 | self.baseModels = basemodels
66 | self.pages=[]
67 | self.pageSize = 1 #response['metadata']['pageSize'] if 'pageSize' in response['metadata'] else None
68 | self.currentPage = 1
69 | page = { 'url': response['requestUrl'],
70 | 'nextUrl': response['metadata']['nextPage'] if 'nextPage' in response['metadata'] else None,
71 | 'prevUrl': None
72 | }
73 | self.pages.append(page)
74 |
75 | def getNextUrl(self) -> str:
76 | return self.pages[self.currentPage-1]['nextUrl']
77 | def getPrevUrl(self) -> str:
78 | return self.pages[self.currentPage-1]['prevUrl']
79 | def getJumpUrl(self, page) -> str:
80 | if page <= len(self.pages):
81 | return self.pages[page-1]['url']
82 | else:
83 | return None
84 | def getPagination(self):
85 | ret = { "types": self.types,
86 | "sort": self.sort,
87 | "searchType": self.searchType,
88 | "searchTerm": self.searchTerm,
89 | "nsfw": self.nsfw,
90 | "period": self.period,
91 | "basemodels": self.baseModels,
92 | "pageSize": len(self.pages),
93 | "currentPage": self.currentPage,
94 | "pages": self.pages
95 | }
96 | return ret
97 | def setPagination(self, pagination:dict):
98 | self.pages = pagination['pages']
99 | self.currentPage = pagination['currentPage']
100 | self.pageSize = pagination['pageSize']
101 |
102 | def nextPage(self, response:dict) -> None:
103 | prevUrl = self.pages[self.currentPage-1]['url']
104 | page = { 'url': response['requestUrl'],
105 | 'nextUrl': response['metadata']['nextPage'] if 'nextPage' in response['metadata'] else None,
106 | 'prevUrl': prevUrl
107 | }
108 | #if self.currentPage + 1 == self.pageSize:
109 | # page['nextUrl'] = None
110 | if self.currentPage < len(self.pages):
111 | self.pages[self.currentPage] = page
112 | else:
113 | self.pages.append(page)
114 | if len(self.pages) > self.pageSize:
115 | self.pageSize = len(self.pages)
116 | self.currentPage += 1
117 | return
118 |
119 | def prevPage(self, response: dict) -> None:
120 | page = {'url': response['requestUrl'],
121 | 'nextUrl': response['metadata']['nextPage'] if 'nextPage' in response['metadata'] else None,
122 | 'prevUrl': None
123 | }
124 | if self.currentPage > 1:
125 | page['prevUrl'] = self.pages[self.currentPage-2]['url']
126 | self.pages[self.currentPage-1] = page
127 | self.currentPage -= 1
128 | return
129 | def pageJump(self, response, pageNum):
130 | page = {'url': response['requestUrl'],
131 | 'nextUrl': response['metadata']['nextPage'] if 'nextPage' in response['metadata'] else None,
132 | 'prevUrl': None
133 | }
134 | if pageNum > 1:
135 | page['prevUrl'] = self.pages[pageNum-1]['prevUrl']
136 | self.pages[pageNum-1] = page
137 | self.currentPage = pageNum
138 | return
139 |
140 | class APIInformation():
141 | baseUrl = "https://civitai.com"
142 | modelsApi = f"{baseUrl}/api/v1/models"
143 | imagesApi = f"{baseUrl}/api/v1/images"
144 | versionsAPI = f"{baseUrl}/api/v1/model-versions"
145 | byHashAPI = f"{baseUrl}/api/v1/model-versions/by-hash"
146 | typeOptions:list = None
147 | sortOptions:list = None
148 | basemodelOptions:list = None
149 | periodOptions:list = None
150 | searchTypes = [
151 | "No",
152 | "Keyword",
153 | "User name",
154 | "Tag",
155 | "Model ID",
156 | "Version ID",
157 | "Hash",
158 | ]
159 | nsfwLevel = {"PG": 1,
160 | "PG-13": 2,
161 | "R": 4,
162 | "X": 8,
163 | "XXX": 16,
164 | #"Blocked": 32,
165 | "Banned": 256,
166 | }
167 | def __init__(self) -> None:
168 | if APIInformation.typeOptions is None:
169 | self.getOptions()
170 | def setBaseUrl(self,url:str):
171 | APIInformation.baseUrl = url
172 | def getBaseUrl(self) -> str:
173 | return APIInformation.baseUrl
174 | def getModelsApiUrl(self, id=None):
175 | url = APIInformation.modelsApi
176 | url += f'/{id}' if id is not None else ""
177 | return url
178 | def getImagesApiUrl(self):
179 | return APIInformation.imagesApi
180 | def getVersionsApiUrl(self, id=None):
181 | url = APIInformation.versionsAPI
182 | url += f'/{id}' if id is not None else ""
183 | return url
184 | def getVersionsByHashUrl(self, hash=None):
185 | url = APIInformation.byHashAPI
186 | url += f'/{hash}' if id is not None else ""
187 | return url
188 | def getTypeOptions(self) -> list:
189 | # global typeOptions, sortOptions, basemodelOptions
190 | return APIInformation.typeOptions
191 | def getSortOptions(self) -> list:
192 | # global typeOptions, sortOptions, basemodelOptions
193 | return APIInformation.sortOptions
194 | def getBasemodelOptions(self) -> list:
195 | # global typeOptions, sortOptions, basemodelOptions
196 | return APIInformation.basemodelOptions
197 | def getPeriodOptions(self) -> list:
198 | return APIInformation.periodOptions
199 | def getSearchTypes(self) -> list:
200 | return APIInformation.searchTypes
201 | def strNsfwLevel(self, nsfwLevel:int) -> str:
202 | keys = []
203 | for k, v in APIInformation.nsfwLevel.items():
204 | if nsfwLevel & v > 0:
205 | keys.append(k)
206 | return ", ".join(keys)
207 |
208 | def requestApiOptions(self, url=None, query=None):
209 | if url is None:
210 | url = self.getModelsApiUrl()
211 | if query is not None:
212 | query = urllib.parse.urlencode(
213 | query, doseq=True, quote_via=urllib.parse.quote)
214 | # print_lc(f'{query=}')
215 |
216 | # Make a GET request to the API
217 | try:
218 | # with requests.Session() as request:
219 | browser = Browser()
220 | response = browser.session.get(
221 | url, params=query, timeout=read_timeout()
222 | )
223 | # print_lc(f'Page cache: {response.headers["CF-Cache-Status"]}')
224 | response.raise_for_status()
225 | except requests.exceptions.RequestException as e:
226 | # print(f"{(response.status_code)=}")
227 | if response.status_code == 400: # Bad Request
228 | # expected under normal conditions
229 | response.encoding = "utf-8"
230 | data = (
231 | json.loads(response.text)
232 | )
233 | else:
234 | data = ""
235 | print_ly("Civitai server may be down or under maintenance.")
236 | else:
237 | data = ""
238 | # Check the status code of the response
239 | # if response.status_code != 200:
240 | # print("Request failed with status code: {}".format(response.status_code))
241 | # exit()
242 | return data
243 |
244 | def getOptions(self):
245 | '''Get choices from Civitai'''
246 | url = self.getModelsApiUrl()
247 | query = { 'types': ""}
248 | data = self.requestApiOptions(url, query)
249 | types = [
250 | "Checkpoint",
251 | "TextualInversion",
252 | "Hypernetwork",
253 | "AestheticGradient",
254 | "LORA",
255 | "LoCon",
256 | "DoRA",
257 | "Controlnet",
258 | "Upscaler",
259 | "MotionModule",
260 | "VAE",
261 | "Poses",
262 | "Wildcards",
263 | "Workflows",
264 | "Detection",
265 | "Other"
266 | ]
267 | try:
268 | # types = data['error']['issues'][0]['unionErrors'][0]['issues'][0]['options']
269 | res = json.loads(data['error']['message'])
270 | newList = res[0]["errors"][0][0]["values"]
271 | # print_lc(f"{res[0]['errors'][0][0]['values']=}")
272 | diff = set(types) ^ set(newList)
273 | if len(diff) != 0:
274 | print_lc(f"Type options have been updated.\n{diff=}")
275 | types = newList
276 | except:
277 | print_ly(f'ERROR: Get types')
278 | else:
279 | # print_lc(f'Set types')
280 | pass
281 | priorityTypes = [
282 | "Checkpoint",
283 | "TextualInversion",
284 | "LORA",
285 | "LoCon",
286 | "DoRA"
287 | ]
288 | dictPriority = {
289 | priorityTypes[i]: priorityTypes[i] for i in range(0, len(priorityTypes))}
290 | dict_types = dictPriority | {types[i]: types[i]
291 | for i in range(0, len(types))}
292 | APIInformation.typeOptions = [dictPriority.get(
293 | key, key) for key, value in dict_types.items()]
294 |
295 | query = {'baseModels': ""}
296 | data = self.requestApiOptions(url, query)
297 | APIInformation.basemodelOptions = [
298 | "AuraFlow",
299 | "Chroma",
300 | "CogVideoX",
301 | "Flux.1 S",
302 | "Flux.1 D",
303 | "Flux.1 Krea",
304 | "Flux.1 Kontext",
305 | "Flux.2 D",
306 | "HiDream",
307 | "Hunyuan 1",
308 | "Hunyuan Video",
309 | "Illustrious",
310 | "Imagen4",
311 | "Kolors",
312 | "LTXV",
313 | "Lumina",
314 | "Mochi",
315 | "Nano Banana",
316 | "NoobAI",
317 | "ODOR",
318 | "OpenAI",
319 | "Other",
320 | "PixArt a",
321 | "PixArt E",
322 | "Playground v2",
323 | "Pony",
324 | "Pony V7",
325 | "Qwen",
326 | "Stable Cascade",
327 | "SD 1.4",
328 | "SD 1.5",
329 | "SD 1.5 LCM",
330 | "SD 1.5 Hyper",
331 | "SD 2.0",
332 | "SD 2.0 768",
333 | "SD 2.1",
334 | "SD 2.1 768",
335 | "SD 2.1 Unclip",
336 | "SD 3",
337 | "SD 3.5",
338 | "SD 3.5 Large",
339 | "SD 3.5 Large Turbo",
340 | "SD 3.5 Medium",
341 | "Sora 2",
342 | "SDXL 0.9",
343 | "SDXL 1.0",
344 | "SDXL 1.0 LCM",
345 | "SDXL Lightning",
346 | "SDXL Hyper",
347 | "SDXL Turbo",
348 | "SDXL Distilled",
349 | "Seedream",
350 | "SVD",
351 | "SVD XT",
352 | "Veo 3",
353 | "Wan Video",
354 | "Wan Video 1.3B t2v",
355 | "Wan Video 14B t2v",
356 | "Wan Video 14B i2v 480p",
357 | "Wan Video 14B i2v 720p",
358 | "Wan Video 2.2 TI2V-5B",
359 | "Wan Video 2.2 I2V-A14B",
360 | "Wan Video 2.2 T2V-A14B",
361 | "Wan Video 2.5 T2V",
362 | "Wan Video 2.5 I2V",
363 | "ZImageTurbo",
364 | ]
365 | try:
366 | # APIInformation.basemodelOptions = data['error']['issues'][0]['unionErrors'][0]['issues'][0]['options']
367 | res = json.loads(data['error']['message'])
368 | # print_lc(f"{res[0]['errors'][0][0]['values']=}")
369 | newList = res[0]["errors"][0][0]["values"]
370 | diff = set(APIInformation.basemodelOptions) ^ set(newList)
371 | if len(diff) != 0:
372 | print_lc(f"Base model options have been updated.\n{diff=}")
373 | APIInformation.basemodelOptions = newList
374 | except:
375 | print_ly(f'ERROR: Get base models')
376 | else:
377 | # print_lc(f'Set base models')
378 | pass
379 |
380 | query = {'sort': ""}
381 | data = self.requestApiOptions(url, query)
382 | APIInformation.sortOptions = [
383 | "Highest Rated",
384 | "Most Downloaded",
385 | "Most Liked",
386 | "Most Discussed",
387 | "Most Collected",
388 | "Most Images",
389 | "Newest",
390 | "Oldest",
391 | ]
392 | try:
393 | # APIInformation.sortOptions = data['error']['issues'][0]['options']
394 | res = json.loads(data['error']['message'])
395 | # print_lc(f"{res[0]['values']=}")
396 | newList = res[0]["values"]
397 | diff = set(APIInformation.sortOptions) ^ set(newList)
398 | if len(diff) != 0:
399 | print_lc(f"Sort options have been updated.\n{diff=}")
400 | APIInformation.sortOptions = newList
401 | except:
402 | print_ly(f'ERROR: Get sorts')
403 | else:
404 | # print_lc(f'Set sorts')
405 | pass
406 |
407 | query = {'period': ""}
408 | data = self.requestApiOptions(url, query)
409 | APIInformation.periodOptions = [
410 | "Day",
411 | "Week",
412 | "Month",
413 | "Year",
414 | "AllTime"
415 | ]
416 | try:
417 | # APIInformation.periodOptions = data['error']['issues'][0]['options']
418 | res = json.loads(data['error']['message'])
419 | # print_lc(f"{res[0]['values']=}")
420 | newList = res[0]["values"]
421 | diff = set(APIInformation.periodOptions) ^ set(newList)
422 | if len(diff) != 0:
423 | print_lc(f"Period options have been updated.\n{diff=}")
424 | APIInformation.periodOptions = newList
425 | except:
426 | print_ly(f'ERROR: Get periods')
427 | else:
428 | # print_lc(f'Set periods')
429 | pass
430 |
431 | class CivitaiModels(APIInformation):
432 | '''CivitaiModels: Handle the response of civitai models api v1.'''
433 | def __init__(self, url:str=None, json_data:dict=None, content_type:str=None):
434 | super().__init__()
435 | self.jsonData = json_data
436 | # self.contentType = content_type
437 | self.showNsfw = False
438 | self.baseUrl = APIInformation.baseUrl if url is None else url
439 | self.modelIndex = None
440 | self.versionsInfo = None # for radio button and file exist check
441 | self.versionIndex = None
442 | self.modelVersionInfo = None
443 | self.requestError = None
444 | self.saveFolder = None
445 | self.cardPagination = None
446 | def updateJsonData(self, json_data:dict=None, content_type:str=None):
447 | '''Update json data.'''
448 | self.jsonData = json_data
449 | # self.contentType = self.contentType if content_type is None else content_type
450 | self.showNsfw = False
451 | self.modelIndex = None
452 | self.versionIndex = None
453 | self.modelVersionInfo = None
454 | self.requestError = None
455 | self.saveFolder = None
456 | def getJsonData(self) -> dict:
457 | return self.jsonData
458 |
459 | def setShowNsfw(self, showNsfw:bool):
460 | self.showNsfw = showNsfw
461 | def isShowNsfw(self) -> bool:
462 | return self.showNsfw
463 | # def setContentType(self, content_type:str):
464 | # self.contentType = content_type
465 | # def getContentType(self) -> str:
466 | # return self.contentType
467 | def getRequestError(self) -> requests.exceptions.RequestException:
468 | return self.requestError
469 | def clearRequestError(self):
470 | self.requestError = None
471 | def setSaveFolder(self, path):
472 | self.saveFolder = path
473 | def getSaveFolder(self):
474 | return self.saveFolder
475 | def matchLevel(self, modelLevel:int, browsingLevel:int) -> bool:
476 | if bool(browsingLevel & self.nsfwLevel["Banned"]) :
477 | # Show banned users
478 | if browsingLevel == self.nsfwLevel["Banned"]:
479 | # Show all if Browsing Level is unchecked
480 | return True
481 | return modelLevel & browsingLevel > 0
482 | else:
483 | if browsingLevel == 0: # Show all if Browsing Level is unchecked
484 | return not bool(modelLevel & self.nsfwLevel["Banned"])
485 | return bool(modelLevel & browsingLevel) and not bool(
486 | modelLevel & self.nsfwLevel["Banned"]
487 | )
488 |
489 | # Models
490 | def getModels(self, showNsfw = False) -> list:
491 | '''Return: [(str: Model name, str: index)]'''
492 | model_list = []
493 | for index, item in enumerate(self.jsonData['items']):
494 | # print_lc(
495 | # f"{item['nsfwLevel']}-{item['nsfwLevel'] & sum(opts.civsfz_browsing_level)}-{opts.civsfz_browsing_level}")
496 | # if (item['nsfwLevel'] & 2*sum(opts.civsfz_browsing_level)-1) > 0:
497 | if showNsfw:
498 | model_list.append((item['name'], index))
499 | elif not self.treatAsNsfw(modelIndex=index): #item['nsfw']:
500 | model_list.append((item['name'], index))
501 | return model_list
502 |
503 | # def getModelNames(self) -> dict: #include nsfw models
504 | # model_dict = {}
505 | # for item in self.jsonData['items']:
506 | # model_dict[item['name']] = item['name']
507 | # return model_dict
508 | # def getModelNamesSfw(self) -> dict: #sfw models
509 | # '''Return SFW items names.'''
510 | # model_dict = {}
511 | # for item in self.jsonData['items']:
512 | # if not item['nsfw']:
513 | # model_dict[item['name']] = item['name']
514 | # return model_dict
515 |
516 | # Model
517 | def getModelNameByID(self, id:int) -> str:
518 | name = None
519 | for item in self.jsonData['items']:
520 | if int(item['id']) == int(id):
521 | name = item['name']
522 | return name
523 | def getIDByModelName(self, name:str) -> str:
524 | id = None
525 | for item in self.jsonData['items']:
526 | if item['name'] == name:
527 | id = int(item['id'])
528 | return id
529 | def getModelNameByIndex(self, index:int) -> str:
530 | return self.jsonData['items'][index]['name']
531 | def isNsfwModelByID(self, id:int) -> bool:
532 | nsfw = None
533 | for item in self.jsonData['items']:
534 | if int(item['id']) == int(id):
535 | nsfw = item['nsfw']
536 | return nsfw
537 | def selectModelByIndex(self, index:int):
538 | if index >= 0 and index < len(self.jsonData['items']):
539 | self.modelIndex = index
540 | return self.modelIndex
541 | def selectModelByID(self, id:int):
542 | for index, item in enumerate(self.jsonData['items']):
543 | if int(item['id']) == int(id):
544 | self.modelIndex = index
545 | return self.modelIndex
546 | def selectModelByName(self, name:str) -> int:
547 | if name is not None:
548 | for index, item in enumerate(self.jsonData['items']):
549 | if item['name'] == name:
550 | self.modelIndex = index
551 | # print(f'{name} - {self.modelIndex}')
552 | return self.modelIndex
553 | def isNsfwModel(self) -> bool:
554 | return self.jsonData['items'][self.modelIndex]['nsfw']
555 | def treatAsNsfw(self, modelIndex=None, versionIndex=None):
556 | modelIndex = self.modelIndex if modelIndex is None else modelIndex
557 | modelIndex = 0 if modelIndex is None else modelIndex
558 | versionIndex = self.versionIndex if versionIndex is None else versionIndex
559 | versionIndex = 0 if versionIndex is None else versionIndex
560 | ret = self.jsonData['items'][modelIndex]['nsfw']
561 | if opts.civsfz_treat_x_as_nsfw:
562 | try:
563 | picNsfw = self.jsonData['items'][modelIndex]['modelVersions'][versionIndex]['images'][0]['nsfwLevel']
564 | except Exception as e:
565 | # print_ly(f'{e}')
566 | pass
567 | else:
568 | # print_lc(f'{picNsfw}')
569 | if picNsfw > 1:
570 | ret = True
571 | return ret
572 | def getIndexByModelName(self, name:str) -> int:
573 | retIndex = None
574 | if name is not None:
575 | for index, item in enumerate(self.jsonData['items']):
576 | if item['name'] == name:
577 | retIndex = index
578 | return retIndex
579 | def getSelectedModelIndex(self) -> int:
580 | return self.modelIndex
581 | def getSelectedModelName(self) -> str:
582 | item = self.jsonData['items'][self.modelIndex]
583 | return item['name']
584 | def getSelectedModelID(self) -> str:
585 | item = self.jsonData['items'][self.modelIndex]
586 | return int(item['id'])
587 | def getSelectedModelType(self) -> str:
588 | item = self.jsonData['items'][self.modelIndex]
589 | return item['type']
590 | def getModelTypeByIndex(self, index:int) -> str:
591 | item = self.jsonData['items'][index]
592 | return item['type']
593 | def getUserName(self):
594 | item = self.jsonData['items'][self.modelIndex]
595 | return item['creator']['username'] if 'creator' in item else ""
596 | def getModelID(self):
597 | item = self.jsonData['items'][self.modelIndex]
598 | return item['id']
599 |
600 | def allows2permissions(self) -> dict:
601 | '''Convert allows to permissions. Select model first.
602 | [->Reference](https://github.com/civitai/civitai/blob/main/src/components/PermissionIndicator/PermissionIndicator.tsx#L15)'''
603 | permissions = {}
604 | if self.modelIndex is None:
605 | print_ly('Select item first.')
606 | else:
607 | if self.modelIndex is not None:
608 | canSellImagesPermissions = {
609 | 'Image'}
610 | canRentCivitPermissions = {'RentCivit'}
611 | canRentPermissions = {'Rent'}
612 | canSellPermissions = {'Sell'}
613 |
614 | item = self.jsonData['items'][self.modelIndex]
615 | allowCommercialUse = set(item['allowCommercialUse'])
616 | allowNoCredit = item['allowNoCredit']
617 | allowDerivatives = item['allowDerivatives']
618 | allowDifferentLicense = item['allowDifferentLicense']
619 |
620 | canSellImages = len(allowCommercialUse & canSellImagesPermissions) > 0
621 | canRentCivit = len(allowCommercialUse & canRentCivitPermissions) > 0
622 | canRent = len(allowCommercialUse & canRentPermissions) > 0
623 | canSell = len(allowCommercialUse & canSellPermissions) > 0
624 |
625 | permissions['allowNoCredit'] = allowNoCredit
626 | permissions['canSellImages'] = canSellImages
627 | permissions['canRentCivit'] = canRentCivit
628 | permissions['canRent'] = canRent
629 | permissions['canSell'] = canSell
630 | permissions['allowDerivatives'] = allowDerivatives
631 | permissions['allowDifferentLicense'] = allowDifferentLicense
632 | return permissions
633 | def getModelVersionsList(self) -> list:
634 | '''Return modelVersions list. Select item before.'''
635 | self.getModelVersionsInfo()
636 | return [(item["name"], i) for i, item in enumerate(self.versionsInfo)]
637 | # versionNames = []
638 | # if self.modelIndex is None:
639 | # print_ly('Select item first.')
640 | # else:
641 | # item = self.jsonData['items'][self.modelIndex]
642 | # versionNames = [ (version['name'],i) for i,version in enumerate(item['modelVersions'])]
643 | # # versionNames[version['name']] = version["name"]
644 | # return versionNames
645 |
646 | def modelVersionsInfo(self) -> list:
647 | return self.versionsInfo
648 |
649 | def getModelVersionsInfo(self) -> list:
650 | info = []
651 | if self.modelIndex is None:
652 | pass
653 | # print_ly("Select item first.")
654 | else:
655 | hasVersions = self.checkAlreadyHave()
656 | # print_lc(f"{hasVersions=}")
657 | item = self.jsonData["items"][self.modelIndex]
658 | info = [
659 | {
660 | "name": l1["name"],
661 | "base_model": l1["baseModel"],
662 | "have": l2,
663 | }
664 | for l1,l2 in zip(item["modelVersions"], hasVersions)
665 | ]
666 | self.versionsInfo = info
667 | return info
668 |
669 | def checkAlreadyHave(self, index:int=None) -> list[bool]:
670 | if index == None:
671 | index = self.modelIndex
672 | item = self.jsonData["items"][index]
673 | hasVersions = []
674 | for i,ver in enumerate(item['modelVersions']):
675 | have = False
676 | for file in ver['files']:
677 | folder = generate_model_save_path2(
678 | item["type"],
679 | item["name"],
680 | ver["baseModel"],
681 | self.treatAsNsfw(modelIndex=index, versionIndex=i),
682 | item["creator"]["username"] if "creator" in item else "",
683 | item["id"],
684 | ver["id"],
685 | ver["name"],
686 | )
687 | # print(f"{folder}")
688 | file_name = file['name']
689 | path_file = folder / Path(file_name)
690 | # print(f"{path_file}")
691 | if isExistFile(folder, file_name):
692 | have = True
693 | break
694 | hasVersions.append(have)
695 | return hasVersions
696 |
697 | # Version
698 | def selectVersionByIndex(self, index:int) -> int:
699 | numVersions = len(self.jsonData['items'][self.modelIndex]['modelVersions'])
700 | index = 0 if index < 0 else index
701 | index = numVersions -1 if index > numVersions-1 else index
702 | self.versionIndex = index
703 | return self.versionIndex
704 | def selectVersionByID(self, ID:int) -> int:
705 | item = self.jsonData['items'][self.modelIndex]
706 | for index, model in enumerate(item['modelVersions']):
707 | if int(model['id']) == int(ID):
708 | self.versionIndex = index
709 | return self.versionIndex
710 | def selectVersionByName(self, name:str) -> int:
711 | '''Select model version by name. Select model first.
712 |
713 | Args:
714 | ID (int): version ID
715 | Returns:
716 | int: index number of the version
717 | '''
718 | if name is not None:
719 | item = self.jsonData['items'][self.modelIndex]
720 | for index, model in enumerate(item['modelVersions']):
721 | if model['name'] == name:
722 | self.versionIndex = index
723 | return self.versionIndex
724 | def getSelectedVersionName(self):
725 | return self.jsonData['items'][self.modelIndex]['modelVersions'][self.versionIndex]['name']
726 | def getSelectedVersionBaseModel(self):
727 | # print(f"{self.jsonData['items'][self.modelIndex]['modelVersions']}")
728 | return self.jsonData['items'][self.modelIndex]['modelVersions'][self.versionIndex]['baseModel']
729 | def getSelectedVersionEarlyAccessTimeFrame(self):
730 | '''
731 | earlyAccessTimeFrame is missing from the API response
732 | '''
733 | return self.jsonData['items'][self.modelIndex]['modelVersions'][self.versionIndex]['earlyAccessTimeFrame']
734 | def getSelectedVersionEarlyAccessDeadline(self):
735 | # if 'earlyAccessDeadline' in self.jsonData['items'][self.modelIndex]['modelVersions'][self.versionIndex]:
736 | # return self.jsonData['items'][self.modelIndex]['modelVersions'][self.versionIndex]['earlyAccessDeadline']
737 | if self.jsonData['items'][self.modelIndex]['modelVersions'][self.versionIndex]['availability'] == "EarlyAccess":
738 | return "EA"
739 | else:
740 | return ""
741 | def setModelVersionInfo(self, modelInfo: str):
742 | self.modelVersionInfo = modelInfo
743 | def getModelVersionInfo(self) -> str:
744 | return self.modelVersionInfo
745 | def getVersionDict(self) -> dict:
746 | version_dict = {}
747 | item = self.jsonData['items'][self.modelIndex]
748 | version_dict = item['modelVersions'][self.versionIndex]
749 | return version_dict
750 |
751 | def getCreatedDatetime(self) -> datetime.datetime :
752 | item = self.jsonData['items'][self.modelIndex]
753 | version_dict = item['modelVersions'][self.versionIndex]
754 | strCreatedAt = version_dict['createdAt'].replace('Z', '+00:00') # < Python 3.11
755 | dtCreatedAt = datetime.datetime.fromisoformat(strCreatedAt)
756 | # print_lc(f'{dtCreatedAt} {dtCreatedAt.tzinfo}')
757 | return dtCreatedAt
758 | def getUpdatedDatetime(self) -> datetime.datetime:
759 | item = self.jsonData['items'][self.modelIndex]
760 | version_dict = item['modelVersions'][self.versionIndex]
761 | strUpdatedAt = version_dict['updatedAt'].replace(
762 | 'Z', '+00:00') # < Python 3.11
763 | dtUpdatedAt = datetime.datetime.fromisoformat(strUpdatedAt)
764 | # print_lc(f'{dtUpdatedAt} {dtUpdatedAt.tzinfo}')
765 | return dtUpdatedAt
766 | def getPublishedDatetime(self) -> datetime.datetime:
767 | item = self.jsonData['items'][self.modelIndex]
768 | version_dict = item['modelVersions'][self.versionIndex]
769 | if version_dict['publishedAt'] is None:
770 | return None
771 | if version_dict['publishedAt'][-1] == "Z":
772 | strPublishedAt = version_dict['publishedAt'].replace(
773 | 'Z', '+00:00') # < Python 3.11
774 | else:
775 | strPublishedAt = version_dict['publishedAt'][:19] + '+00:00'
776 | # print_lc(f'{version_dict["publishedAt"]=} {strPublishedAt=}')
777 | dtPublishedAt = datetime.datetime.fromisoformat(strPublishedAt)
778 | # print_lc(f'{dtPublishedAt} {dtPublishedAt.tzinfo}')
779 | return dtPublishedAt
780 | def getEarlyAccessDeadlineDatetime(self) -> datetime.datetime:
781 | item = self.jsonData['items'][self.modelIndex]
782 | version_dict = item['modelVersions'][self.versionIndex]
783 | if 'earlyAccessDeadline' in version_dict:
784 | strEarlyAccessDeadline = version_dict['earlyAccessDeadline'].replace(
785 | 'Z', '+00:00') # < Python 3.11
786 | dtEarlyAccessDeadline = datetime.datetime.fromisoformat(strEarlyAccessDeadline)
787 | else:
788 | dtEarlyAccessDeadline=""
789 | # print_lc(f'{dtPublishedAt} {dtPublishedAt.tzinfo}')
790 | return dtEarlyAccessDeadline
791 | def getVersionID(self):
792 | item = self.jsonData['items'][self.modelIndex]
793 | version_dict = item['modelVersions'][self.versionIndex]
794 | return version_dict['id']
795 |
796 | def addMetaVID(self, vID, modelInfo: dict) -> dict:
797 | versionRes = self.requestVersionByVersionID(vID)
798 | if versionRes is not None:
799 | # print_lc(f'{len(modelInfo["modelVersions"][0]["images"])}-{len(versionRes["images"])}')
800 | for i, img in enumerate(versionRes["images"]):
801 | # if 'meta' not in modelInfo["modelVersions"][0]["images"][i]:
802 | modelInfo["modelVersions"][0]["images"][i]["meta"] = img['meta'] if 'meta' in img else {
803 | }
804 | for i, img in enumerate(modelInfo["modelVersions"][0]["images"]):
805 | if 'meta' not in img:
806 | img["meta"] = {
807 | "WARNING": "Model Version API has no information"}
808 | else:
809 | for i, img in enumerate(modelInfo["modelVersions"][0]["images"]):
810 | if 'meta' not in img:
811 | img["meta"] = {
812 | "WARNING": "Model Version API request error"}
813 | return modelInfo
814 |
815 | def addMetaIID(self, vID:dict, modelInfo:dict) -> dict:
816 | imagesRes = self.requestImagesByVersionId(vID)
817 | if self.requestError is not None:
818 | print_ly(f"Version ID API Request Error: Fail to get meta info.")
819 | if imagesRes is not None:
820 | IDs = {item["id"]: item["meta"] for item in imagesRes["items"] if 'meta' in item}
821 | for i, img in enumerate(modelInfo["modelVersions"][0]["images"]):
822 | if 'id' not in img:
823 | # Extract image ID from url
824 | id = re.findall(r'/(\d+)\.\w+$', img['url'])
825 | # print_lc(f'{img["url"]} {id=}')
826 | if 'id' not in img:
827 | img['id'] = int(id[0])
828 |
829 | if 'id' in img:
830 | if img['id'] in IDs.keys():
831 | img['meta']=IDs[img['id']]
832 | else:
833 | img['meta'] = {
834 | "WARNING": "Images API has no information"}
835 | else:
836 | img['meta'] = {
837 | "WARNING": "Version ID API response does not include Image ID. Therefore the Infotext cannot be determined. Try searching by model name."}
838 | else:
839 | for i, img in enumerate(modelInfo["modelVersions"][0]["images"]):
840 | if 'meta' not in img:
841 | img["meta"] = {"ERROR": "Images API request error"}
842 | return modelInfo
843 |
844 | def makeModelInfo2(self, modelIndex=None, versionIndex=None, nsfwLevel=0) -> dict:
845 | """make selected version info"""
846 | modelIndex = self.modelIndex if modelIndex is None else modelIndex
847 | versionIndex = self.versionIndex if versionIndex is None else versionIndex
848 | item = self.jsonData["items"][modelIndex]
849 | version = item["modelVersions"][versionIndex]
850 | modelInfo = {"infoVersion": "2.4"}
851 | for key, value in item.items():
852 | if key not in ("modelVersions"):
853 | modelInfo[key] = value
854 | modelInfo["allow"] = {}
855 | modelInfo["allow"]["allowNoCredit"] = item["allowNoCredit"]
856 | modelInfo["allow"]["allowCommercialUse"] = item["allowCommercialUse"]
857 | modelInfo["allow"]["allowDerivatives"] = item["allowDerivatives"]
858 | modelInfo["allow"]["allowDifferentLicense"] = item["allowDifferentLicense"]
859 | modelInfo["modelVersions"] = [version]
860 | modelInfo["modelVersions"][0]["files"] = version["files"]
861 | modelInfo["modelVersions"][0]["images"] = version["images"]
862 | # add version info
863 | modelInfo['versionId'] = version['id']
864 | modelInfo['versionName'] = version['name']
865 | # modelInfo['createdAt'] = version['createdAt']
866 | # modelInfo['updatedAt'] = version['updatedAt']
867 | modelInfo['publishedAt'] = version['publishedAt']
868 | modelInfo['trainedWords'] = version['trainedWords'] if 'trainedWords' in version else ""
869 | modelInfo['baseModel'] = version['baseModel']
870 | modelInfo['versionDescription'] = version['description'] if 'description' in version else None
871 | modelInfo["downloadUrl"] = (
872 | version["downloadUrl"] if "downloadUrl" in version else None
873 | )
874 | # self.addMetaVID(version["id"], modelInfo)
875 | self.addMetaIID(version["id"], modelInfo)
876 | html = self.modelInfoHtml(modelInfo, nsfwLevel)
877 | modelInfo["html"] = html
878 | modelInfo["html0"] = self.modelInfoHtml(modelInfo, 0)
879 | self.setModelVersionInfo(modelInfo)
880 | return modelInfo
881 |
882 | def getUrlByName(self, model_filename=None):
883 | if self.modelIndex is None:
884 | # print(Fore.LIGHTYELLOW_EX + f'getUrlByName: Select model first. {model_filename}' + Style.RESET_ALL )
885 | return
886 | if self.versionIndex is None:
887 | # print(Fore.LIGHTYELLOW_EX + f'getUrlByName: Select version first. {model_filename}' + Style.RESET_ALL )
888 | return
889 | # print(Fore.LIGHTYELLOW_EX + f'File name . {model_filename}' + Style.RESET_ALL )
890 | item = self.jsonData['items'][self.modelIndex]
891 | version = item['modelVersions'][self.versionIndex]
892 | dl_url = None
893 | for file in version['files']:
894 | if file['name'] == model_filename:
895 | dl_url = file['downloadUrl']
896 | return dl_url
897 | def getHashByName(self, model_filename=None):
898 | if self.modelIndex is None:
899 | # print(Fore.LIGHTYELLOW_EX + f'getUrlByName: Select model first. {model_filename}' + Style.RESET_ALL )
900 | return
901 | if self.versionIndex is None:
902 | # print(Fore.LIGHTYELLOW_EX + f'getUrlByName: Select version first. {model_filename}' + Style.RESET_ALL )
903 | return
904 | # print(Fore.LIGHTYELLOW_EX + f'File name . {model_filename}' + Style.RESET_ALL )
905 | item = self.jsonData['items'][self.modelIndex]
906 | version = item['modelVersions'][self.versionIndex]
907 | sha256 = ""
908 | for file in version['files']:
909 | # print_lc(f'{file["hashes"]=}')
910 | if file['name'] == model_filename and 'SHA256' in file['hashes']:
911 | sha256 = file['hashes']['SHA256']
912 | return sha256
913 |
914 | # Pages
915 | def addFirstPage(self, response:dict, types:list=None, sort:str=None, searchType:str=None,
916 | searchTerm:str=None, nsfw:bool=None, period:str=None, basemodels:list=None) -> None:
917 | self.cardPagination = ModelCardsPagination(response, types, sort, searchType, searchTerm, nsfw, period, basemodels)
918 | # print_lc(f'{self.cardPagination.getPagination()=}')
919 | def addNextPage(self, response:dict) -> None:
920 | self.cardPagination.nextPage(response)
921 | def backPage(self, response:dict) -> None:
922 | self.cardPagination.prevPage(response)
923 | def getJumpUrl(self, page) -> str:
924 | return self.cardPagination.getJumpUrl(page)
925 | def pageJump(self, response:dict, page) -> None:
926 | self.cardPagination.pageJump(response, page)
927 | def getPagination(self):
928 | return self.cardPagination.getPagination()
929 |
930 | def getCurrentPage(self) -> str:
931 | # return f"{self.jsonData['metadata']['currentPage']}"
932 | return self.cardPagination.currentPage if self.cardPagination is not None else 0
933 | def getTotalPages(self) -> str:
934 | # return f"{self.jsonData['metadata']['totalPages']}"
935 | # return f"{self.jsonData['metadata']['pageSize']}"
936 | return self.cardPagination.pageSize
937 | def getPages(self) -> str:
938 | return f"{self.getCurrentPage()}/{self.getTotalPages()}"
939 | def nextPage(self) -> str:
940 | # return self.jsonData['metadata']['nextPage'] if 'nextPage' in self.jsonData['metadata'] else None
941 | return self.cardPagination.getNextUrl() if self.cardPagination is not None else None
942 | def prevPage(self) -> str:
943 | # return self.jsonData['metadata']['prevPage'] if 'prevPage' in self.jsonData['metadata'] else None
944 | return self.cardPagination.getPrevUrl() if self.cardPagination is not None else None
945 |
946 | # HTML
947 | # Make model cards html
948 | def modelCardsHtml(self, models, jsID=0, nsfwLevel=0):
949 | '''Generate HTML of model cards.'''
950 | # NG List
951 | # txtNG = opts.civsfz_ban_creators # Comma-separated text
952 | # txtFav = opts.civsfz_favorite_creators # Comma-separated text
953 | # ngUsers = [s.strip() for s in txtNG.split(",") if s.strip()]
954 | # favUsers = [s.strip() for s in txtFav.split(",") if s.strip()]
955 | cards = []
956 | for model in models:
957 | index = model[1]
958 | item = self.jsonData['items'][model[1]]
959 | creator = item["creator"]["username"] if "creator" in item else ""
960 | level = item["nsfwLevel"] | (0 if not creator in BanCreators.getAsList() else self.nsfwLevel["Banned"])
961 | base_model = ""
962 | param = {
963 | "name": item["name"],
964 | "index": index,
965 | "jsId": jsID,
966 | "id": item["id"],
967 | "isNsfw": False,
968 | "nsfwLevel": level,
969 | "matchLevel": self.matchLevel(level, nsfwLevel),
970 | "type": item["type"],
971 | "have": "",
972 | "ea": "",
973 | "imgType": "",
974 | "creator": creator,
975 | "ngUser": creator in BanCreators.getAsList(),
976 | "favorite": creator in FavoriteCreators.getAsList(),
977 | }
978 | if any(item['modelVersions']):
979 | # if len(item['modelVersions'][0]['images']) > 0:
980 | # default image
981 | for i, img in enumerate(item['modelVersions'][0]['images']):
982 | if i == 0: # 0 as default
983 | param['imgType'] = img['type']
984 | param['imgsrc'] = img["url"]
985 | if img['nsfwLevel'] > 1 and not self.isShowNsfw():
986 | param['isNsfw'] = True
987 | if self.matchLevel(img['nsfwLevel'], nsfwLevel):
988 | # img = item['modelVersions'][0]['images'][0]
989 | param['imgType'] = img['type']
990 | param['imgsrc'] = img["url"]
991 | if img['nsfwLevel'] > 1 and not self.isShowNsfw():
992 | param['isNsfw'] = True
993 | break
994 | base_model = item["modelVersions"][0]['baseModel']
995 | param['baseModel'] = base_model
996 |
997 | has = self.checkAlreadyHave(index)
998 | if has[0]:
999 | param["have"] = "new"
1000 | elif any(has[1:]) :
1001 | param["have"] = "old"
1002 |
1003 | # ea = item["modelVersions"][0]['earlyAccessDeadline'] if "earlyAccessDeadline" in item["modelVersions"][0] else ""
1004 | ea = item["modelVersions"][0]['availability'] == "EarlyAccess"
1005 | if ea:
1006 | # strEA = item["modelVersions"][0]['earlyAccessDeadline'].replace('Z', '+00:00') # < Python 3.11
1007 | # dtEA = datetime.datetime.fromisoformat(strEA)
1008 | # dtNow = datetime.datetime.now(datetime.timezone.utc)
1009 | # if dtNow < dtEA:
1010 | param['ea'] = 'in'
1011 | cards.append(param)
1012 |
1013 | forTrigger = f'' # for trigger event
1014 | dictBasemodelColor = dictBasemodelColors(self.getBasemodelOptions())
1015 | template = environment.get_template("cardlist.jinja")
1016 | content = template.render(
1017 | forTrigger=forTrigger,
1018 | cards=cards,
1019 | dictBasemodelColor=dictBasemodelColor,
1020 | cardNoPreview=card_no_preview,
1021 | )
1022 | return content
1023 | def modelNameTitleHtml(self, name:str, vname:str, base:str, upuser:str="", ea:str=""):
1024 | dictBasemodelColor = dictBasemodelColors(self.getBasemodelOptions())
1025 | template = environment.get_template("modelTitle.jinja")
1026 | content = template.render(
1027 | modelName=name, versionName=vname, baseModel=base, uploadUser=upuser, dictBasemodelColor=dictBasemodelColor, ea=ea,
1028 | )
1029 | return content
1030 |
1031 | def meta2html(self, meta:dict) -> str:
1032 | # convert key name as infotext
1033 | sortKey = [
1034 | 'prompt',
1035 | 'negativePrompt',
1036 | 'Model',
1037 | 'VAE',
1038 | 'sampler',
1039 | 'Schedule type',
1040 | 'cfgScale',
1041 | 'steps',
1042 | 'seed',
1043 | 'clipSkip',
1044 | ]
1045 | infotextDict = { key: meta[key] if key in meta else None for key in sortKey }
1046 | infotextDict.update(meta)
1047 | # print(f"{infotextDict=}")
1048 | # if 'hashes' in infotextDict:
1049 | # print(type(infotextDict['hashes']))
1050 |
1051 | template = environment.get_template("infotext.jinja")
1052 | content = template.render(infotext=infotextDict)
1053 | return content
1054 |
1055 | def meta2infotext(self, meta:dict) -> str:
1056 | # convert key name as infotext
1057 | renameKey = {
1058 | 'prompt':'Prompt',
1059 | 'negativePrompt': 'Negative prompt',
1060 | 'sampler': 'Sampler',
1061 | 'steps': 'Steps',
1062 | 'seed': 'Seed',
1063 | 'cfgScale': 'CFG scale',
1064 | 'clipSkip': 'Clip skip'
1065 | }
1066 | infotextDict = {renameKey.get(key, key): value for key, value in meta.items()} if meta is not None else None
1067 | # print(f"{infotextDict=}")
1068 | infotext = ""
1069 | if 'Prompt' in infotextDict:
1070 | infotext += infotextDict['Prompt'] + "\n"
1071 | if 'Negative prompt' in infotextDict:
1072 | infotext += "Negative prompt: " + infotextDict['Negative prompt']
1073 | infotext += "\n"
1074 | tmpList:list = []
1075 | for key, value in infotextDict.items():
1076 | if not key in ('Prompt','Negative prompt'):
1077 | tmpList.append("{}:{}".format(key,value))
1078 | infotext += ",".join(tmpList)
1079 | return infotext
1080 |
1081 | def modelInfoHtml(self, modelInfo:dict, nsfwLevel:int=0) -> str:
1082 | '''Generate HTML of model info'''
1083 | samples = ""
1084 | for pic in modelInfo["modelVersions"][0]["images"]:
1085 | if self.matchLevel(pic['nsfwLevel'], nsfwLevel):
1086 | nsfw = pic['nsfwLevel'] > 1 and not self.showNsfw
1087 | infotext = self.meta2infotext(pic['meta']) if pic['meta'] is not None else ""
1088 | metaHtml = self.meta2html(pic['meta']) if pic['meta'] is not None else ""
1089 | template = environment.get_template("sampleImage.jinja")
1090 | samples += template.render(
1091 | pic=pic,
1092 | nsfw=nsfw,
1093 | infotext=infotext,
1094 | metaHtml=metaHtml
1095 | )
1096 | filesize = modelInfo["modelVersions"][0]["files"][0]["sizeKB"]
1097 | # Get primary file size
1098 | fileIndex = 0
1099 | for i, file in enumerate(modelInfo["modelVersions"][0]["files"]):
1100 | if "primary" in file:
1101 | fileIndex = i
1102 |
1103 | # created = self.getCreatedDatetime().astimezone(
1104 | # tz.tzlocal()).replace(microsecond=0).isoformat()
1105 | if self.getPublishedDatetime() is not None:
1106 | published = self.getPublishedDatetime().astimezone(
1107 | tz.tzlocal()).replace(microsecond=0).isoformat()
1108 | else:
1109 | published = ""
1110 | # updated = self.getUpdatedDatetime().astimezone(
1111 | # tz.tzlocal()).replace(microsecond=0).isoformat()
1112 | dictBasemodelColor = dictBasemodelColors(self.getBasemodelOptions())
1113 | template = environment.get_template("modelbasicinfo.jinja")
1114 | basicInfo = template.render(
1115 | modelInfo=modelInfo,
1116 | published=published,
1117 | strNsfw=self.strNsfwLevel(modelInfo["nsfwLevel"]),
1118 | strVNsfw=self.strNsfwLevel(modelInfo["modelVersions"][0]["nsfwLevel"]),
1119 | fileIndex=fileIndex,
1120 | dictBasemodelColor=dictBasemodelColor,
1121 | )
1122 |
1123 | permissions = self.permissionsHtml(self.allows2permissions())
1124 | # function:copy to clipboard
1125 | js = (
1126 | ""
1144 | )
1145 | template = environment.get_template("modelInfo.jinja")
1146 | content = template.render(
1147 | modelInfo=modelInfo, basicInfo=basicInfo, permissions=permissions,
1148 | samples=samples, js=js)
1149 |
1150 | return content
1151 |
1152 | def permissionsHtml(self, premissions:dict, msgType:int=3) -> str:
1153 | template = environment.get_template("permissions.jinja")
1154 | content = template.render(premissions)
1155 | return content
1156 |
1157 | # REST API
1158 | def makeRequestQuery(self, content_type, sort_type, period, search_type, base_models=None, grChkboxShowNsfw=False,
1159 | grDrpdwnKeyword="",
1160 | grDrpdwnUserName="",
1161 | grDrpdwnTag="",
1162 | grDrpdwnID="",
1163 | grchkbxfav=""
1164 | ):
1165 | if grDrpdwnID is not None:
1166 | grDrpdwnID = str.strip(grDrpdwnID)
1167 | if "Model ID" in search_type or "Version ID" in search_type:
1168 | if not grDrpdwnID.isdecimal():
1169 | query = ""
1170 | print_ly(f'"{grDrpdwnID}" is not a numerical value')
1171 | else:
1172 | query = str.strip(grDrpdwnID)
1173 | elif "Hash" in search_type:
1174 | try:
1175 | int("0x" + grDrpdwnID, 16)
1176 | isHex = True
1177 | except ValueError:
1178 | isHex = False
1179 | if isHex:
1180 | query = str.strip(grDrpdwnID)
1181 | else:
1182 | query = ""
1183 | print_ly(f'"{grDrpdwnID}" is not a hexadecimal value')
1184 | else:
1185 | query = {'types': content_type, 'sort': sort_type,
1186 | 'limit': opts.civsfz_number_of_cards, 'page': 1, 'nsfw': grChkboxShowNsfw}
1187 | if not period == "AllTime":
1188 | query |= {'period': period}
1189 | if "User name" in search_type:
1190 | query |= {'username': grDrpdwnUserName }
1191 | if "Tag" in search_type:
1192 | query |= {'tag': grDrpdwnTag }
1193 | if "Keyword" in search_type:
1194 | query |= {"query": grDrpdwnKeyword}
1195 | query.pop("page", None) # Cannot use page param with query search
1196 | if base_models:
1197 | query |= {'baseModels': base_models }
1198 | if grchkbxfav:
1199 | query |= {"favorites": grchkbxfav}
1200 | return query
1201 |
1202 | def updateQuery(self, url:str , addQuery:dict) -> str:
1203 | parse = urllib.parse.urlparse(url)
1204 | strQuery = parse.query
1205 | dictQuery = urllib.parse.parse_qs(strQuery)
1206 | query = dictQuery | addQuery
1207 | newURL = parse._replace(query=urllib.parse.urlencode(query, doseq=True, quote_via=urllib.parse.quote))
1208 | return urllib.parse.urlunparse(newURL)
1209 |
1210 | def requestApi(self, url=None, query=None, timeout=read_timeout()):
1211 | self.requestError = None
1212 | hasFavorites = "favorites" in query if query else False
1213 | if url is None:
1214 | url = self.getModelsApiUrl()
1215 | if query is not None:
1216 | # Replace Boolean with a lowercase string True->true, False->false
1217 | query = { k: str(v).lower() if isinstance(v, bool) else v
1218 | for k, v in query.items()}
1219 | # print_lc(f'{query=}')
1220 | query = urllib.parse.urlencode(query, doseq=True, quote_via=urllib.parse.quote)
1221 |
1222 | # Make a GET request to the API
1223 | # cachePath = Path.joinpath(extensionFolder(), "../api_cache")
1224 | # headers = {'Cache-Control': 'no-cache'} if cache else {}
1225 | try:
1226 | # with CachedSession(cache_name=cachePath.resolve(), expire_after=5*60) as session:
1227 | browse = Browser()
1228 | if hasFavorites:
1229 | api_key=getattr(opts,"civsfz_api_key", None)
1230 | if api_key is None:
1231 | print_ly(f"No API Key.")
1232 | else:
1233 | browse.setAPIKey(api_key)
1234 | response = browse.session.get(
1235 | url, params=query, timeout=read_timeout()
1236 | )
1237 | # print_lc(f'{response.url=}')
1238 | # print_lc(f'Page cache: {response.headers["CF-Cache-Status"]}')
1239 | response.raise_for_status()
1240 | except requests.exceptions.RequestException as e:
1241 | # print(Fore.LIGHTYELLOW_EX + "Request error: " , e)
1242 | # print(Style.RESET_ALL)
1243 | print_ly(f"Request error: {e}")
1244 | # print(f"{response=}")
1245 | browse.reConnect()
1246 | data = self.jsonData # No update data
1247 | self.requestError = e
1248 | else:
1249 | # print_lc(f'{response.url=}')
1250 | response.encoding = "utf-8" # response.apparent_encoding
1251 | data = json.loads(response.text)
1252 | data['requestUrl'] = response.url
1253 | data = self.patchResponse(data)
1254 | return data
1255 |
1256 | def patchResponse(self, data:dict) -> dict:
1257 | # make compatibility
1258 | # if 'metadata' in data:
1259 | # print_lc(f"{data['metadata']=}")
1260 | # parse = urllib.parse.urlparse(data['metadata']['nextPage'])
1261 | # strQuery = parse.query
1262 | # dictQuery = urllib.parse.parse_qs(strQuery)
1263 | # dictQuery.pop('cursor', None)
1264 | # addQuery = { 'page': data['metadata']['currentPage']}
1265 | # query = dictQuery | addQuery
1266 | # currentURL = parse._replace(query=urllib.parse.urlencode(query, doseq=True, quote_via=urllib.parse.quote))
1267 | # data['metadata']['currentPageUrl'] = urllib.parse.urlunparse(currentURL)
1268 | # if data['metadata']['currentPage'] == data['metadata']['pageSize']:
1269 | # data['metadata']['nextPage'] = None
1270 | # if data['metadata']['currentPage'] < data['metadata']['pageSize']:
1271 | # addQuery ={ 'page': data['metadata']['currentPage'] + 1 }
1272 | # query = dictQuery | addQuery
1273 | # query.pop('cursor', None)
1274 | # nextPage = parse._replace(query=urllib.parse.urlencode(query, doseq=True, quote_via=urllib.parse.quote))
1275 | # data['metadata']['nextPage'] = urllib.parse.urlunparse(nextPage)
1276 | ##if data['metadata']['currentPage'] > 1:
1277 | # addQuery ={ 'page': data['metadata']['currentPage'] - 1 }
1278 | # query = dictQuery | addQuery
1279 | # query.pop('cursor', None)
1280 | # prevURL = parse._replace(query=urllib.parse.urlencode(query, doseq=True, quote_via=urllib.parse.quote))
1281 | # data['metadata']['prevPage'] = prevURL
1282 |
1283 | return data
1284 |
1285 | def requestImagesByVersionId(self, versionId=None, limit=None):
1286 | if versionId == None:
1287 | return None
1288 | params = {"modelVersionId": versionId,
1289 | "sort": "Oldest",
1290 | 'nsfw': 'X'}
1291 | if limit is not None:
1292 | params |= {"limit": limit}
1293 | return self.requestApi(self.getImagesApiUrl(), params, timeout=read_timeout())
1294 | def requestVersionByVersionID(self, versionID=None):
1295 | if versionID == None:
1296 | return None
1297 | url = self.getVersionsApiUrl(versionID)
1298 | ret = self.requestApi(url, timeout=read_timeout())
1299 | if self.requestError is not None:
1300 | ret = None
1301 | return ret
1302 |
--------------------------------------------------------------------------------