From 00f9129b324600ceaf12f94ea74a40cc48b19da0 Mon Sep 17 00:00:00 2001 From: Christian Rupp Date: Sat, 22 Apr 2023 11:36:29 +0200 Subject: [PATCH 01/55] Add a ComicRack Metadata Extractor for cbz based Stash Galleries --- plugins/comicInfoExtractor/README.md | 12 ++ .../comicInfoExtractor/comicInfoExtractor.py | 124 ++++++++++++++++++ .../comicInfoExtractor/comicInfoExtractor.yml | 18 +++ plugins/comicInfoExtractor/config.yml | 11 ++ plugins/comicInfoExtractor/requirements.txt | 2 + 5 files changed, 167 insertions(+) create mode 100644 plugins/comicInfoExtractor/README.md create mode 100644 plugins/comicInfoExtractor/comicInfoExtractor.py create mode 100644 plugins/comicInfoExtractor/comicInfoExtractor.yml create mode 100644 plugins/comicInfoExtractor/config.yml create mode 100644 plugins/comicInfoExtractor/requirements.txt diff --git a/plugins/comicInfoExtractor/README.md b/plugins/comicInfoExtractor/README.md new file mode 100644 index 00000000..759952c3 --- /dev/null +++ b/plugins/comicInfoExtractor/README.md @@ -0,0 +1,12 @@ +# Comic Archive Metadata Extractor +Follows the Comicrack Standard for saving Comic Metadata in .cbz files by reading the ComicInfo.xml file in the archive and writing the result into the stash gallery. +Use the config.py ImportList to define what XML names should be mapped to what. +Currently, Bookmark and Type are recognized as chapters that are imported as well. +The current Configuration will overwrite any value you try to set that is already set in the ComicInfo.xml. For a change in that, change the hook condition in the yml. + +### Installation +Move the `comicInfoExtractor` directory into Stash's plugins directory, reload plugins. + +### Tasks +* Load all cbz Metadata - Fetch metadata for all galleries. +* Post update hook - Fetch metadata for that gallery diff --git a/plugins/comicInfoExtractor/comicInfoExtractor.py b/plugins/comicInfoExtractor/comicInfoExtractor.py new file mode 100644 index 00000000..1fda7f1c --- /dev/null +++ b/plugins/comicInfoExtractor/comicInfoExtractor.py @@ -0,0 +1,124 @@ +import stashapi.log as log +from stashapi.stashapp import StashInterface +import stashapi.marker_parse as mp +import yaml +import json +import os +import sys +import xml.etree.ElementTree as ET +import zipfile + +per_page = 100 + +def processGallery(g): + #Read ComicInfo.xml File + if len(g["files"]) == 0: + log.info(g["id"] + " is not an archive. No scanning for Comic Metadata.") + return + comicInfo = False + with zipfile.ZipFile(g["files"][0]["path"], 'r') as archive: + archivecontent = [x.lower() for x in archive.namelist()] + for archivefile in archivecontent: + if archivefile.lower() == "comicinfo.xml": + comicInfo = ET.fromstring(archive.read("ComicInfo.xml")) + if not comicInfo: + log.info(g["files"][0]["path"] + " does not contain a ComicInfo.xml file. No scan will be triggered.") + return + + #Adjust names for giving ids + for key in ImportList.keys(): + if ImportList[key] == "tags": + ImportList[key] = "tag_ids" + if ImportList[key] == "performers": + ImportList[key] = "performer_ids" + if ImportList[key] == "studio": + ImportList[key] = "studio_id" + + #Get Metadata from ComicInfo.xml + galleryData = {"id": g["id"]} + for item in ImportList.keys(): + value = comicInfo.find(item) + if value != None: + galleryData[ImportList[item]] = value.text + chapterData = [] + pageData = comicInfo.find("Pages") + if pageData: + for page in pageData: + if page.get("Bookmark"): + chapterData.append({"image_index": int(page.get("Image")) + 1, "title": page.get("Bookmark")}) + if page.get("Type"): + chapterData.append({"image_index": int(page.get("Image")) + 1, "title": page.get("Type")}) + + #Adjust the retrieved data if necessary + for data in galleryData.keys(): + if data in ["tag_ids", "performer_ids"]: + galleryData[data] = [x.strip() for x in galleryData[data].split(",")] + if data == "tag_ids": + tagids = [] + for tag in galleryData[data]: + tagids.append(stash.find_tag(tag, create=True)["id"]) + galleryData[data] = tagids + if data == "performer_ids": + performerids = [] + for performer in galleryData[data]: + performerids.append(stash.find_performer(performer, create=True)["id"]) + galleryData[data] = performerids + if data == "studio_id": + galleryData[data] = stash.find_studio(galleryData[data], create=True)["id"] + if data == "date": + galleryData[data] = galleryData[data] + "-01-01" + if data == "organized": + galleryData[data] = eval(galleryData[data].lower().capitalize()) + if data == "rating100": + galleryData[data] = int(galleryData[data]) + + #Add Chapter if it does not exist and finally update Gallery Metadata + for chapter in chapterData: + addChapter = True + for existingChapter in g["chapters"]: + if existingChapter["title"] == chapter["title"] and existingChapter["image_index"] == chapter["image_index"]: + addChapter = False + if addChapter: + stash.create_gallery_chapter({"title": chapter["title"], "image_index": chapter["image_index"], "gallery_id": g["id"]}) + stash.update_gallery(galleryData) + + + +def processAll(): + log.info('Getting gallery count') + count=stash.find_galleries(f={},filter={"per_page": 1},get_count=True)[0] + log.info(str(count)+' galleries to scan.') + for r in range(1,int(count/per_page)+1): + log.info('processing '+str(r*per_page)+ ' - '+str(count)) + galleries=stash.find_galleries(f={},filter={"page":r,"per_page": per_page}) + for g in galleries: + processGallery(g) + + + +#Start of the Program +json_input = json.loads(sys.stdin.read()) +FRAGMENT_SERVER = json_input["server_connection"] +stash = StashInterface(FRAGMENT_SERVER) + +#Load Config +with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yml"), "r") as f: + try: + config = yaml.safe_load(f) + except yaml.YAMLError as exc: + log.error("Could not load config.yml: " + str(exc)) + sys.exit(1) +try: + ImportList=config["ImportList"] +except KeyError as key: + log.error(str(key) + " is not defined in config.yml, but is needed for this script to proceed") + sys.exit(1) + +if 'mode' in json_input['args']: + PLUGIN_ARGS = json_input['args']["mode"] + if 'process' in PLUGIN_ARGS: + processAll() +elif 'hookContext' in json_input['args']: + id=json_input['args']['hookContext']['id'] + gallery=stash.find_gallery(id) + processGallery(gallery) diff --git a/plugins/comicInfoExtractor/comicInfoExtractor.yml b/plugins/comicInfoExtractor/comicInfoExtractor.yml new file mode 100644 index 00000000..fc10bf32 --- /dev/null +++ b/plugins/comicInfoExtractor/comicInfoExtractor.yml @@ -0,0 +1,18 @@ +name: Comic Info Extractor +description: Extract the metadata from cbz with the Comicrack standard (ComicInfo.xml) +version: 0.1 +url: https://github.com/stashapp/CommunityScripts/ +exec: + - "/usr/bin/python3" + - "{pluginDir}/comicInfoExtractor.py" +interface: raw +hooks: + - name: Add Metadata to Gallery + description: Update Metadata for Gallery by evaluating the ComicInfo.xml. + triggeredBy: + - Gallery.Update.Post +tasks: + - name: Load all cbz Metadata + description: Get Metadata for all Galleries by looking for ComicInfo.xml files in the Archive. + defaultArgs: + mode: process diff --git a/plugins/comicInfoExtractor/config.yml b/plugins/comicInfoExtractor/config.yml new file mode 100644 index 00000000..51c7d1f5 --- /dev/null +++ b/plugins/comicInfoExtractor/config.yml @@ -0,0 +1,11 @@ +#ImportList is a dictionary +#that matches an xml Attribute from ComicInfo.xml to the according value in stash (using the graphql naming) +#Fields that refer to different types of media are resolved by name and created if necessary (tags, studio, performers) +#Fields that can contain multiple values (tags, performers) will be expected as a comma separated string, like +#Outdoor, Blonde +ImportList: + Genre: tags + Title: title + Writer: studio + Year: date + Summary: details diff --git a/plugins/comicInfoExtractor/requirements.txt b/plugins/comicInfoExtractor/requirements.txt new file mode 100644 index 00000000..4e5ec4c0 --- /dev/null +++ b/plugins/comicInfoExtractor/requirements.txt @@ -0,0 +1,2 @@ +stashapp-tools +pyyaml From b7409be7242ed77e79ade6e3918bf6bacef10461 Mon Sep 17 00:00:00 2001 From: Tweeticoats <60335703+Tweeticoats@users.noreply.github.com> Date: Tue, 25 Apr 2023 07:26:37 +0930 Subject: [PATCH 02/55] [timestampTrade] Fix bugs with data submission (#159) --- plugins/timestampTrade/timestampTrade.py | 77 +++++++++++++++-------- plugins/timestampTrade/timestampTrade.yml | 2 +- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/plugins/timestampTrade/timestampTrade.py b/plugins/timestampTrade/timestampTrade.py index 7d4c7a5e..633d773a 100644 --- a/plugins/timestampTrade/timestampTrade.py +++ b/plugins/timestampTrade/timestampTrade.py @@ -5,8 +5,13 @@ import sys import requests import json +import time +import math + per_page = 100 +request_s = requests.Session() + def processScene(s): if len(s['stash_ids']) > 0: @@ -19,7 +24,7 @@ def processScene(s): 'api returned something, for scene: ' + s['title'] + ' marker count: ' + str(len(md['marker']))) markers = [] for m in md['marker']: - log.debug('-- ' + m['name'] + ", " + str(m['start'] / 1000)) +# log.debug('-- ' + m['name'] + ", " + str(m['start'] / 1000)) marker = {} marker["seconds"] = m['start'] / 1000 marker["primary_tag"] = m["tag"] @@ -35,38 +40,60 @@ def processAll(): log.info('Getting scene count') count=stash.find_scenes(f={"stash_id":{"value":"","modifier":"NOT_NULL"},"has_markers":"false"},filter={"per_page": 1},get_count=True)[0] log.info(str(count)+' scenes to submit.') + i=0 for r in range(1,int(count/per_page)+1): - log.info('processing '+str(r*per_page)+ ' - '+str(count)) + log.info('fetching data: %s - %s %0.1f%%' % ((r - 1) * per_page,r * per_page,(i/count)*100,)) scenes=stash.find_scenes(f={"stash_id":{"value":"","modifier":"NOT_NULL"},"has_markers":"false"},filter={"page":r,"per_page": per_page}) for s in scenes: processScene(s) + i=i+1 + log.progress((i/count)) + time.sleep(2) def submit(): + scene_fgmt = """title + details + url + date + performers{ + name + stash_ids{ + endpoint + stash_id + } + } + tags{ + name + } + studio{ + name + stash_ids{ + endpoint + stash_id + } + } + stash_ids{ + endpoint + stash_id + } + scene_markers{ + title + seconds + primary_tag{ + name + } + }""" count = stash.find_scenes(f={"has_markers": "true"}, filter={"per_page": 1}, get_count=True)[0] - for r in range(1, int(count / per_page) + 2): - log.info('processing ' + str((r - 1) * per_page) + ' - ' + str(r * per_page) + ' / ' + str(count)) - scenes = stash.find_scenes(f={"has_markers": "true"}, filter={"page": r, "per_page": per_page}) + i=0 + for r in range(1, math.ceil(count/per_page) + 1): + log.info('submitting scenes: %s - %s %0.1f%%' % ((r - 1) * per_page,r * per_page,(i/count)*100,)) + scenes = stash.find_scenes(f={"has_markers": "true"}, filter={"page": r, "per_page": per_page},fragment=scene_fgmt) for s in scenes: - # Cleanup, remove fields that are not needed by the api like ratings, file paths etc - for x in ['id', 'checksum', 'oshash', 'phash', 'rating', 'organized', 'o_counter', 'file','path', 'galleries']: - s.pop(x, None) - for t in s['tags']: - for x in ['id', 'image_path', 'scene_count', 'primary_tag']: - t.pop(x, None) - for t in s['performers']: - for x in ['id', 'checksum', 'scene_count', 'image_path', 'image_count', 'gallery_count', 'favorite', - 'tags']: - t.pop(x, None) - for m in s['scene_markers']: - for x in ['id', 'scene', 'tags']: - m.pop(x, None) - for x in ['id', 'aliases', 'image_path', 'scene_count']: - m['primary_tag'].pop(x, None) - - print("submitting scene: " + str(s)) - requests.post('https://timestamp.trade/submit-stash', json=s) - - + log.debug("submitting scene: " + str(s)) + request_s.post('https://timestamp.trade/submit-stash', json=s) + i=i+1 + log.progress((i/count)) + time.sleep(2) diff --git a/plugins/timestampTrade/timestampTrade.yml b/plugins/timestampTrade/timestampTrade.yml index e30fa1e6..1d4ebb26 100644 --- a/plugins/timestampTrade/timestampTrade.yml +++ b/plugins/timestampTrade/timestampTrade.yml @@ -1,6 +1,6 @@ name: Timestamp Trade description: Sync Markers with timestamp.trade, a new database for sharing markers. -version: 0.1 +version: 0.2 url: https://github.com/stashapp/CommunityScripts/ exec: - python From 21f3fcdad8273edc199b80f4c9fe0d12b05536bf Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Mon, 24 Apr 2023 17:59:19 -0400 Subject: [PATCH 03/55] [phashDuplicateTagger] update import reference after lib changes (#161) --- plugins/phashDuplicateTagger/phashDuplicateTagger.py | 4 ++-- plugins/phashDuplicateTagger/phashDuplicateTagger.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index 242736aa..9f9ee53d 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -6,7 +6,7 @@ try: import stashapi.log as log from stashapi.tools import human_bytes - from stashapi.types import PhashDistance + from stashapi.stash_types import PhashDistance from stashapi.stashapp import StashInterface except ModuleNotFoundError: print("You need to install the stashapi module. (pip install stashapp-tools)", @@ -266,4 +266,4 @@ def clean_titles(): }) if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.yml b/plugins/phashDuplicateTagger/phashDuplicateTagger.yml index a64f5253..9a789fcc 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.yml +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.yml @@ -20,7 +20,7 @@ name: "PHash Duplicate Tagger" description: Will tag scenes based on duplicate PHashes for easier/safer removal. -version: 0.1.0 +version: 0.1.1 url: https://github.com/Darklyter/CommunityScripts exec: - python From 6614023027c9e6e10c9a81be5bb9162a77f9afa2 Mon Sep 17 00:00:00 2001 From: yoshnopa <40072150+yoshnopa@users.noreply.github.com> Date: Wed, 26 Apr 2023 09:16:46 +0200 Subject: [PATCH 04/55] Trigger ComicRack Metadata Extraction on Creation --- plugins/comicInfoExtractor/comicInfoExtractor.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/comicInfoExtractor/comicInfoExtractor.yml b/plugins/comicInfoExtractor/comicInfoExtractor.yml index fc10bf32..f9cb7440 100644 --- a/plugins/comicInfoExtractor/comicInfoExtractor.yml +++ b/plugins/comicInfoExtractor/comicInfoExtractor.yml @@ -11,6 +11,7 @@ hooks: description: Update Metadata for Gallery by evaluating the ComicInfo.xml. triggeredBy: - Gallery.Update.Post + - Gallery.Create.Post tasks: - name: Load all cbz Metadata description: Get Metadata for all Galleries by looking for ComicInfo.xml files in the Archive. From b8fb92e63ae1cb254fc1783044f692c6914fe023 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Mon, 21 Aug 2023 16:53:16 -0500 Subject: [PATCH 05/55] Provide stashUserscriptLibrary as a plugin --- .../StashUserscriptLibrary.yml | 6 + .../stashUserscriptLibrary.js | 990 ++++++++++++++++++ 2 files changed, 996 insertions(+) create mode 100644 plugins/1. stashUserscriptLibrary/StashUserscriptLibrary.yml create mode 100644 plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js diff --git a/plugins/1. stashUserscriptLibrary/StashUserscriptLibrary.yml b/plugins/1. stashUserscriptLibrary/StashUserscriptLibrary.yml new file mode 100644 index 00000000..2a7d126e --- /dev/null +++ b/plugins/1. stashUserscriptLibrary/StashUserscriptLibrary.yml @@ -0,0 +1,6 @@ +name: Stash Userscript Library +description: Exports utility functions and a Stash class that emits events whenever a GQL response is received and whenenever a page navigation change is detected +version: 1.0 +ui: + javascript: + - stashUserscriptLibrary.js diff --git a/plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js b/plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js new file mode 100644 index 00000000..2ed1d45d --- /dev/null +++ b/plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js @@ -0,0 +1,990 @@ +const stashListener = new EventTarget(); + +function concatRegexp(reg, exp) { + let flags = reg.flags + exp.flags; + flags = Array.from(new Set(flags.split(''))).join(); + return new RegExp(reg.source + exp.source, flags); +} + +class Logger { + constructor(enabled) { + this.enabled = enabled; + } + debug() { + if (!this.enabled) return; + console.debug(...arguments); + } +} + + +class Stash extends EventTarget { + constructor({ + pageUrlCheckInterval = 1, + logging = false + } = {}) { + super(); + this.log = new Logger(logging); + this._pageUrlCheckInterval = pageUrlCheckInterval; + this.fireOnHashChangesToo = true; + this.pageURLCheckTimer = setInterval(() => { + // Loop every 500ms + if (this.lastPathStr !== location.pathname || this.lastQueryStr !== location.search || (this.fireOnHashChangesToo && this.lastHashStr !== location.hash)) { + this.lastPathStr = location.pathname; + this.lastQueryStr = location.search; + this.lastHashStr = location.hash; + this.gmMain(); + } + }, this._pageUrlCheckInterval); + stashListener.addEventListener('response', (evt) => { + if (evt.detail.data?.plugins) { + this.getPluginVersion(evt.detail); + } + this.processRemoteScenes(evt.detail); + this.processScene(evt.detail); + this.processScenes(evt.detail); + this.processStudios(evt.detail); + this.processPerformers(evt.detail); + this.processApiKey(evt.detail); + this.dispatchEvent(new CustomEvent('stash:response', { + 'detail': evt.detail + })); + }); + stashListener.addEventListener('pluginVersion', (evt) => { + if (this.pluginVersion !== evt.detail) { + this.pluginVersion = evt.detail; + this.dispatchEvent(new CustomEvent('stash:pluginVersion', { + 'detail': evt.detail + })); + } + }); + this.version = [0, 0, 0]; + this.getVersion(); + this.pluginVersion = null; + this.getPlugins().then(plugins => this.getPluginVersion(plugins)); + this.visiblePluginTasks = ['Userscript Functions']; + this.settingsCallbacks = []; + this.settingsId = 'userscript-settings'; + this.remoteScenes = {}; + this.scenes = {}; + this.studios = {}; + this.performers = {}; + this.userscripts = []; + } + async getVersion() { + const reqData = { + "operationName": "", + "variables": {}, + "query": `query version { + version { + version + } +} +` + }; + const data = await this.callGQL(reqData); + const versionString = data.data.version.version; + this.version = versionString.substring(1).split('.').map(o => parseInt(o)); + } + compareVersion(minVersion) { + let [currMajor, currMinor, currPatch = 0] = this.version; + let [minMajor, minMinor, minPatch = 0] = minVersion.split('.').map(i => parseInt(i)); + if (currMajor > minMajor) return 1; + if (currMajor < minMajor) return -1; + if (currMinor > minMinor) return 1; + if (currMinor < minMinor) return -1; + return 0; + + } + comparePluginVersion(minPluginVersion) { + if (!this.pluginVersion) return -1; + let [currMajor, currMinor, currPatch = 0] = this.pluginVersion.split('.').map(i => parseInt(i)); + let [minMajor, minMinor, minPatch = 0] = minPluginVersion.split('.').map(i => parseInt(i)); + if (currMajor > minMajor) return 1; + if (currMajor < minMajor) return -1; + if (currMinor > minMinor) return 1; + if (currMinor < minMinor) return -1; + return 0; + + } + async runPluginTask(pluginId, taskName, args = []) { + const reqData = { + "operationName": "RunPluginTask", + "variables": { + "plugin_id": pluginId, + "task_name": taskName, + "args": args + }, + "query": "mutation RunPluginTask($plugin_id: ID!, $task_name: String!, $args: [PluginArgInput!]) {\n runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args: $args)\n}\n" + }; + return this.callGQL(reqData); + } + async callGQL(reqData) { + const options = { + method: 'POST', + body: JSON.stringify(reqData), + headers: { + 'Content-Type': 'application/json' + } + } + + try { + const res = await window.fetch('/graphql', options); + this.log.debug(res); + return res.json(); + } catch (err) { + console.error(err); + } + } + async getFreeOnesStats(link) { + try { + const doc = await fetch(link) + .then(function(response) { + // When the page is loaded convert it to text + return response.text() + }) + .then(function(html) { + // Initialize the DOM parser + var parser = new DOMParser(); + + // Parse the text + var doc = parser.parseFromString(html, "text/html"); + + // You can now even select part of that html as you would in the regular DOM + // Example: + // var docArticle = doc.querySelector('article').innerHTML; + + console.log(doc); + return doc + }) + .catch(function(err) { + console.log('Failed to fetch page: ', err); + }); + + var data = new Object(); + data.rank = doc.querySelector('rank-chart-button'); + console.log(data.rank); + data.views = doc.querySelector('.d-none.d-m-flex.flex-column.align-items-center.global-header > div.font-weight-bold').textContent; + data.votes = '0' + return JSON.stringify(data); + } catch (err) { + console.error(err); + } + } + async getPlugins() { + const reqData = { + "operationName": "Plugins", + "variables": {}, + "query": `query Plugins { + plugins { + id + name + description + url + version + tasks { + name + description + __typename + } + hooks { + name + description + hooks + } + } + } + ` + }; + return this.callGQL(reqData); + } + async getPluginVersion(plugins) { + let version = null; + for (const plugin of plugins?.data?.plugins || []) { + if (plugin.id === 'userscript_functions') { + version = plugin.version; + } + } + stashListener.dispatchEvent(new CustomEvent('pluginVersion', { + 'detail': version + })); + } + async getStashBoxes() { + const reqData = { + "operationName": "Configuration", + "variables": {}, + "query": `query Configuration { + configuration { + general { + stashBoxes { + endpoint + api_key + name + } + } + } + }` + }; + return this.callGQL(reqData); + } + async getApiKey() { + const reqData = { + "operationName": "Configuration", + "variables": {}, + "query": `query Configuration { + configuration { + general { + apiKey + } + } + }` + }; + return this.callGQL(reqData); + } + matchUrl(location, fragment) { + const regexp = concatRegexp(new RegExp(location.origin), fragment); + this.log.debug(regexp, location.href.match(regexp)); + return location.href.match(regexp) != null; + } + createSettings() { + waitForElementId('configuration-tabs-tabpane-system', async (elementId, el) => { + let section; + if (!document.getElementById(this.settingsId)) { + section = document.createElement("div"); + section.setAttribute('id', this.settingsId); + section.classList.add('setting-section'); + section.innerHTML = `

Userscript Settings

`; + el.appendChild(section); + + const expectedApiKey = (await this.getApiKey())?.data?.configuration?.general?.apiKey || ''; + const expectedUrl = window.location.origin; + + const serverUrlInput = await this.createSystemSettingTextbox(section, 'userscript-section-server-url', 'userscript-server-url', 'Stash Server URL', '', 'Server URL…', true); + serverUrlInput.addEventListener('change', () => { + const value = serverUrlInput.value || ''; + if (value) { + this.updateConfigValueTask('STASH', 'url', value); + alert(`Userscripts plugin server URL set to ${value}`); + } else { + this.getConfigValueTask('STASH', 'url').then(value => { + serverUrlInput.value = value; + }); + } + }); + serverUrlInput.disabled = true; + serverUrlInput.value = expectedUrl; + this.getConfigValueTask('STASH', 'url').then(value => { + if (value !== expectedUrl) { + return this.updateConfigValueTask('STASH', 'url', expectedUrl); + } + }); + + const apiKeyInput = await this.createSystemSettingTextbox(section, 'userscript-section-server-apikey', 'userscript-server-apikey', 'Stash API Key', '', 'API Key…', true); + apiKeyInput.addEventListener('change', () => { + const value = apiKeyInput.value || ''; + this.updateConfigValueTask('STASH', 'api_key', value); + if (value) { + alert(`Userscripts plugin server api key set to ${value}`); + } else { + alert(`Userscripts plugin server api key value cleared`); + } + }); + apiKeyInput.disabled = true; + apiKeyInput.value = expectedApiKey; + this.getConfigValueTask('STASH', 'api_key').then(value => { + if (value !== expectedApiKey) { + return this.updateConfigValueTask('STASH', 'api_key', expectedApiKey); + } + }); + } else { + section = document.getElementById(this.settingsId); + } + + for (const callback of this.settingsCallbacks) { + callback(this.settingsId, section); + } + + if (this.pluginVersion) { + this.dispatchEvent(new CustomEvent('stash:pluginVersion', { + 'detail': this.pluginVersion + })); + } + + }); + } + addSystemSetting(callback) { + const section = document.getElementById(this.settingsId); + if (section) { + callback(this.settingsId, section); + } + this.settingsCallbacks.push(callback); + } + async createSystemSettingCheckbox(containerEl, settingsId, inputId, settingsHeader, settingsSubheader) { + const section = document.createElement("div"); + section.setAttribute('id', settingsId); + section.classList.add('card'); + section.style.display = 'none'; + section.innerHTML = `
+
+

${settingsHeader}

+
${settingsSubheader}
+
+
+
+ + +
+
+
`; + containerEl.appendChild(section); + return document.getElementById(inputId); + } + async createSystemSettingTextbox(containerEl, settingsId, inputId, settingsHeader, settingsSubheader, placeholder, visible) { + const section = document.createElement("div"); + section.setAttribute('id', settingsId); + section.classList.add('card'); + section.style.display = visible ? 'flex' : 'none'; + section.innerHTML = `
+
+

${settingsHeader}

+
${settingsSubheader}
+
+
+
+ +
+
+
`; + containerEl.appendChild(section); + return document.getElementById(inputId); + } + get serverUrl() { + return window.location.origin; + } + gmMain() { + const location = window.location; + this.log.debug(URL, window.location); + + // marker wall + if (this.matchUrl(location, /\/scenes\/markers/)) { + this.log.debug('[Navigation] Wall-Markers Page'); + this.dispatchEvent(new Event('page:markers')); + } + // scene page + else if (this.matchUrl(location, /\/scenes\/\d+/)) { + this.log.debug('[Navigation] Scene Page'); + this.dispatchEvent(new Event('page:scene')); + } + // scenes wall + else if (this.matchUrl(location, /\/scenes\?/)) { + this.processTagger(); + this.dispatchEvent(new Event('page:scenes')); + } + + // images wall + if (this.matchUrl(location, /\/images\?/)) { + this.log.debug('[Navigation] Wall-Images Page'); + this.dispatchEvent(new Event('page:images')); + } + // image page + if (this.matchUrl(location, /\/images\/\d+/)) { + this.log.debug('[Navigation] Image Page'); + this.dispatchEvent(new Event('page:image')); + } + + // movie scenes page + else if (this.matchUrl(location, /\/movies\/\d+\?/)) { + this.log.debug('[Navigation] Movie Page - Scenes'); + this.processTagger(); + this.dispatchEvent(new Event('page:movie:scenes')); + } + // movie page + else if (this.matchUrl(location, /\/movies\/\d+/)) { + this.log.debug('[Navigation] Movie Page'); + this.dispatchEvent(new Event('page:movie')); + } + // movies wall + else if (this.matchUrl(location, /\/movies\?/)) { + this.log.debug('[Navigation] Wall-Movies Page'); + this.dispatchEvent(new Event('page:movies')); + } + + // galleries wall + if (this.matchUrl(location, /\/galleries\?/)) { + this.log.debug('[Navigation] Wall-Galleries Page'); + this.dispatchEvent(new Event('page:galleries')); + } + // gallery page + if (this.matchUrl(location, /\/galleries\/\d+/)) { + this.log.debug('[Navigation] Gallery Page'); + this.dispatchEvent(new Event('page:gallery')); + } + + // performer scenes page + if (this.matchUrl(location, /\/performers\/\d+\?/)) { + this.log.debug('[Navigation] Performer Page - Scenes'); + this.processTagger(); + this.dispatchEvent(new Event('page:performer:scenes')); + } + // performer appearswith page + if (this.matchUrl(location, /\/performers\/\d+\/appearswith/)) { + this.log.debug('[Navigation] Performer Page - Appears With'); + this.processTagger(); + this.dispatchEvent(new Event('page:performer:performers')); + } + // performer galleries page + else if (this.matchUrl(location, /\/performers\/\d+\/galleries/)) { + this.log.debug('[Navigation] Performer Page - Galleries'); + this.dispatchEvent(new Event('page:performer:galleries')); + } + // performer movies page + else if (this.matchUrl(location, /\/performers\/\d+\/movies/)) { + this.log.debug('[Navigation] Performer Page - Movies'); + this.dispatchEvent(new Event('page:performer:movies')); + } + // performer page + else if (this.matchUrl(location, /\/performers\//)) { + this.log.debug('[Navigation] Performers Page'); + this.dispatchEvent(new Event('page:performer')); + this.dispatchEvent(new Event('page:performer:details')); + + waitForElementClass('performer-tabs', (className, targetNode) => { + const observerOptions = { + childList: true + } + const observer = new MutationObserver(mutations => { + let isPerformerEdit = false; + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node.id === 'performer-edit') { + isPerformerEdit = true; + } + }); + }); + if (isPerformerEdit) { + this.dispatchEvent(new Event('page:performer:edit')); + } else { + this.dispatchEvent(new Event('page:performer:details')); + } + }); + observer.observe(targetNode[0], observerOptions); + }); + } + // performers wall + else if (this.matchUrl(location, /\/performers\?/)) { + this.log.debug('[Navigation] Wall-Performers Page'); + this.dispatchEvent(new Event('page:performers')); + } + + // studio galleries page + if (this.matchUrl(location, /\/studios\/\d+\/galleries/)) { + this.log.debug('[Navigation] Studio Page - Galleries'); + this.dispatchEvent(new Event('page:studio:galleries')); + } + // studio images page + else if (this.matchUrl(location, /\/studios\/\d+\/images/)) { + this.log.debug('[Navigation] Studio Page - Images'); + this.dispatchEvent(new Event('page:studio:images')); + } + // studio performers page + else if (this.matchUrl(location, /\/studios\/\d+\/performers/)) { + this.log.debug('[Navigation] Studio Page - Performers'); + this.dispatchEvent(new Event('page:studio:performers')); + } + // studio movies page + else if (this.matchUrl(location, /\/studios\/\d+\/movies/)) { + this.log.debug('[Navigation] Studio Page - Movies'); + this.dispatchEvent(new Event('page:studio:movies')); + } + // studio childstudios page + else if (this.matchUrl(location, /\/studios\/\d+\/childstudios/)) { + this.log.debug('[Navigation] Studio Page - Child Studios'); + this.dispatchEvent(new Event('page:studio:childstudios')); + } + // studio scenes page + else if (this.matchUrl(location, /\/studios\/\d+\?/)) { + this.log.debug('[Navigation] Studio Page - Scenes'); + this.processTagger(); + this.dispatchEvent(new Event('page:studio:scenes')); + } + // studio page + else if (this.matchUrl(location, /\/studios\/\d+/)) { + this.log.debug('[Navigation] Studio Page'); + this.dispatchEvent(new Event('page:studio')); + } + // studios wall + else if (this.matchUrl(location, /\/studios\?/)) { + this.log.debug('[Navigation] Wall-Studios Page'); + this.dispatchEvent(new Event('page:studios')); + } + + // tag galleries page + if (this.matchUrl(location, /\/tags\/\d+\/galleries/)) { + this.log.debug('[Navigation] Tag Page - Galleries'); + this.dispatchEvent(new Event('page:tag:galleries')); + } + // tag images page + else if (this.matchUrl(location, /\/tags\/\d+\/images/)) { + this.log.debug('[Navigation] Tag Page - Images'); + this.dispatchEvent(new Event('page:tag:images')); + } + // tag markers page + else if (this.matchUrl(location, /\/tags\/\d+\/markers/)) { + this.log.debug('[Navigation] Tag Page - Markers'); + this.dispatchEvent(new Event('page:tag:markers')); + } + // tag performers page + else if (this.matchUrl(location, /\/tags\/\d+\/performers/)) { + this.log.debug('[Navigation] Tag Page - Performers'); + this.dispatchEvent(new Event('page:tag:performers')); + } + // tag scenes page + else if (this.matchUrl(location, /\/tags\/\d+\?/)) { + this.log.debug('[Navigation] Tag Page - Scenes'); + this.processTagger(); + this.dispatchEvent(new Event('page:tag:scenes')); + } + // tag page + else if (this.matchUrl(location, /\/tags\/\d+/)) { + this.log.debug('[Navigation] Tag Page'); + this.dispatchEvent(new Event('page:tag')); + } + // tags any page + if (this.matchUrl(location, /\/tags\/\d+/)) { + this.log.debug('[Navigation] Tag Page - Any'); + this.dispatchEvent(new Event('page:tag:any')); + } + // tags wall + else if (this.matchUrl(location, /\/tags\?/)) { + this.log.debug('[Navigation] Wall-Tags Page'); + this.dispatchEvent(new Event('page:tags')); + } + + // settings page tasks tab + if (this.matchUrl(location, /\/settings\?tab=tasks/)) { + this.log.debug('[Navigation] Settings Page Tasks Tab'); + this.dispatchEvent(new Event('page:settings:tasks')); + this.hidePluginTasks(); + } + // settings page system tab + else if (this.matchUrl(location, /\/settings\?tab=system/)) { + this.log.debug('[Navigation] Settings Page System Tab'); + this.createSettings(); + this.dispatchEvent(new Event('page:settings:system')); + } + // settings page (defaults to tasks tab) + else if (this.matchUrl(location, /\/settings/)) { + this.log.debug('[Navigation] Settings Page Tasks Tab'); + this.dispatchEvent(new Event('page:settings:tasks')); + this.hidePluginTasks(); + } + + // stats page + if (this.matchUrl(location, /\/stats/)) { + this.log.debug('[Navigation] Stats Page'); + this.dispatchEvent(new Event('page:stats')); + } + } + hidePluginTasks() { + // hide userscript functions plugin tasks + waitForElementByXpath("//div[@id='tasks-panel']//h3[text()='Userscript Functions']/ancestor::div[contains(@class, 'setting-group')]", (elementId, el) => { + const tasks = el.querySelectorAll('.setting'); + for (const task of tasks) { + const taskName = task.querySelector('h3').innerText; + task.classList.add(this.visiblePluginTasks.indexOf(taskName) === -1 ? 'd-none' : 'd-flex'); + this.dispatchEvent(new CustomEvent('stash:plugin:task', { + 'detail': { + taskName, + task + } + })); + } + }); + } + async updateConfigValueTask(sectionKey, propName, value) { + return this.runPluginTask("userscript_functions", "Update Config Value", [{ + "key": "section_key", + "value": { + "str": sectionKey + } + }, { + "key": "prop_name", + "value": { + "str": propName + } + }, { + "key": "value", + "value": { + "str": value + } + }]); + } + async getConfigValueTask(sectionKey, propName) { + await this.runPluginTask("userscript_functions", "Get Config Value", [{ + "key": "section_key", + "value": { + "str": sectionKey + } + }, { + "key": "prop_name", + "value": { + "str": propName + } + }]); + + // poll logs until plugin task output appears + const prefix = `[Plugin / Userscript Functions] get_config_value: [${sectionKey}][${propName}] =`; + return this.pollLogsForMessage(prefix); + } + async pollLogsForMessage(prefix) { + const reqTime = Date.now(); + const reqData = { + "variables": {}, + "query": `query Logs { + logs { + time + level + message + } + }` + }; + await new Promise(r => setTimeout(r, 500)); + let retries = 0; + while (true) { + const delay = 2 ** retries * 100; + await new Promise(r => setTimeout(r, delay)); + retries++; + + const logs = await this.callGQL(reqData); + for (const log of logs.data.logs) { + const logTime = Date.parse(log.time); + if (logTime > reqTime && log.message.startsWith(prefix)) { + return log.message.replace(prefix, '').trim(); + } + } + + if (retries >= 5) { + throw `Poll logs failed for message: ${prefix}`; + } + } + } + processTagger() { + waitForElementByXpath("//button[text()='Scrape All']", (xpath, el) => { + this.dispatchEvent(new CustomEvent('tagger', { + 'detail': el + })); + + const searchItemContainer = document.querySelector('.tagger-container').lastChild; + + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node?.classList?.contains('entity-name') && node.innerText.startsWith('Performer:')) { + this.dispatchEvent(new CustomEvent('tagger:mutation:add:remoteperformer', { + 'detail': { + node, + mutation + } + })); + } else if (node?.classList?.contains('entity-name') && node.innerText.startsWith('Studio:')) { + this.dispatchEvent(new CustomEvent('tagger:mutation:add:remotestudio', { + 'detail': { + node, + mutation + } + })); + } else if (node.tagName === 'SPAN' && node.innerText.startsWith('Matched:')) { + this.dispatchEvent(new CustomEvent('tagger:mutation:add:local', { + 'detail': { + node, + mutation + } + })); + } else if (node.tagName === 'UL') { + this.dispatchEvent(new CustomEvent('tagger:mutation:add:container', { + 'detail': { + node, + mutation + } + })); + } else if (node?.classList?.contains('col-lg-6')) { + this.dispatchEvent(new CustomEvent('tagger:mutation:add:subcontainer', { + 'detail': { + node, + mutation + } + })); + } else if (node.tagName === 'H5') { // scene date + this.dispatchEvent(new CustomEvent('tagger:mutation:add:date', { + 'detail': { + node, + mutation + } + })); + } else if (node.tagName === 'DIV' && node?.classList?.contains('d-flex') && node?.classList?.contains('flex-column')) { // scene stashid, url, details + this.dispatchEvent(new CustomEvent('tagger:mutation:add:detailscontainer', { + 'detail': { + node, + mutation + } + })); + } else { + this.dispatchEvent(new CustomEvent('tagger:mutation:add:other', { + 'detail': { + node, + mutation + } + })); + } + }); + }); + this.dispatchEvent(new CustomEvent('tagger:mutations:searchitems', { + 'detail': mutations + })); + }); + observer.observe(searchItemContainer, { + childList: true, + subtree: true + }); + + const taggerContainerHeader = document.querySelector('.tagger-container-header'); + const taggerContainerHeaderObserver = new MutationObserver(mutations => { + this.dispatchEvent(new CustomEvent('tagger:mutations:header', { + 'detail': mutations + })); + }); + taggerContainerHeaderObserver.observe(taggerContainerHeader, { + childList: true, + subtree: true + }); + + for (const searchItem of document.querySelectorAll('.search-item')) { + this.dispatchEvent(new CustomEvent('tagger:searchitem', { + 'detail': searchItem + })); + } + + if (!document.getElementById('progress-bar')) { + const progressBar = createElementFromHTML(`
`); + progressBar.classList.add('progress'); + progressBar.style.display = 'none'; + taggerContainerHeader.appendChild(progressBar); + } + }); + waitForElementByXpath("//div[@class='tagger-container-header']/div/div[@class='row']/h4[text()='Configuration']", (xpath, el) => { + this.dispatchEvent(new CustomEvent('tagger:configuration', { + 'detail': el + })); + }); + } + setProgress(value) { + const progressBar = document.getElementById('progress-bar'); + if (progressBar) { + progressBar.firstChild.style.width = value + '%'; + progressBar.style.display = (value <= 0 || value > 100) ? 'none' : 'flex'; + } + } + processRemoteScenes(data) { + if (data.data?.scrapeMultiScenes) { + for (const matchResults of data.data.scrapeMultiScenes) { + for (const scene of matchResults) { + this.remoteScenes[scene.remote_site_id] = scene; + } + } + } else if (data.data?.scrapeSingleScene) { + for (const scene of data.data.scrapeSingleScene) { + this.remoteScenes[scene.remote_site_id] = scene; + } + } + } + processScene(data) { + if (data.data.findScene) { + this.scenes[data.data.findScene.id] = data.data.findScene; + } + } + processScenes(data) { + if (data.data.findScenes?.scenes) { + for (const scene of data.data.findScenes.scenes) { + this.scenes[scene.id] = scene; + } + } + } + processStudios(data) { + if (data.data.findStudios?.studios) { + for (const studio of data.data.findStudios.studios) { + this.studios[studio.id] = studio; + } + } + } + processPerformers(data) { + if (data.data.findPerformers?.performers) { + for (const performer of data.data.findPerformers.performers) { + this.performers[performer.id] = performer; + } + } + } + processApiKey(data) { + if (data.data.generateAPIKey != null && this.pluginVersion) { + this.updateConfigValueTask('STASH', 'api_key', data.data.generateAPIKey); + } + } + parseSearchItem(searchItem) { + const urlNode = searchItem.querySelector('a.scene-link'); + const url = new URL(urlNode.href); + const id = url.pathname.replace('/scenes/', ''); + const data = this.scenes[id]; + const nameNode = searchItem.querySelector('a.scene-link > div.TruncatedText'); + const name = nameNode.innerText; + const queryInput = searchItem.querySelector('input.text-input'); + const performerNodes = searchItem.querySelectorAll('.performer-tag-container'); + + return { + urlNode, + url, + id, + data, + nameNode, + name, + queryInput, + performerNodes + } + } + parseSearchResultItem(searchResultItem) { + const remoteUrlNode = searchResultItem.querySelector('.scene-details .optional-field .optional-field-content a'); + const remoteId = remoteUrlNode?.href.split('/').pop(); + const remoteUrl = remoteUrlNode?.href ? new URL(remoteUrlNode.href) : null; + const remoteData = this.remoteScenes[remoteId]; + + const sceneDetailNodes = searchResultItem.querySelectorAll('.scene-details .optional-field .optional-field-content'); + let urlNode = null; + let detailsNode = null; + for (const sceneDetailNode of sceneDetailNodes) { + if (remoteData?.url === sceneDetailNode.innerText) { + urlNode = sceneDetailNode; + } else if (remoteData?.details === sceneDetailNode.textContent) { + detailsNode = sceneDetailNode; + } + } + + const imageNode = searchResultItem.querySelector('.scene-image-container .optional-field .optional-field-content'); + + const metadataNode = searchResultItem.querySelector('.scene-metadata'); + const titleNode = metadataNode.querySelector('h4 .optional-field .optional-field-content'); + const dateNode = metadataNode.querySelector('h5 .optional-field .optional-field-content'); + + const entityNodes = searchResultItem.querySelectorAll('.entity-name'); + let studioNode = null; + const performerNodes = []; + for (const entityNode of entityNodes) { + if (entityNode.innerText.startsWith('Studio:')) { + studioNode = entityNode; + } else if (entityNode.innerText.startsWith('Performer:')) { + performerNodes.push(entityNode); + } + } + + const matchNodes = searchResultItem.querySelectorAll('div.col-lg-6 div.mt-2 div.row.no-gutters.my-2 span.ml-auto'); + const matches = [] + for (const matchNode of matchNodes) { + let matchType = null; + const entityNode = matchNode.parentElement.querySelector('.entity-name'); + + const matchName = matchNode.querySelector('.optional-field-content b').innerText; + const remoteName = entityNode.querySelector('b').innerText; + + let data; + if (entityNode.innerText.startsWith('Performer:')) { + matchType = 'performer'; + if (remoteData) { + data = remoteData.performers.find(performer => performer.name === remoteName); + } + } else if (entityNode.innerText.startsWith('Studio:')) { + matchType = 'studio'; + if (remoteData) { + data = remoteData.studio + } + } + + matches.push({ + matchType, + matchNode, + entityNode, + matchName, + remoteName, + data + }); + } + + return { + remoteUrlNode, + remoteId, + remoteUrl, + remoteData, + urlNode, + detailsNode, + imageNode, + titleNode, + dateNode, + studioNode, + performerNodes, + matches + } + } +} + +stash = new Stash(); + +function waitForElementClass(elementId, callBack, time) { + time = (typeof time !== 'undefined') ? time : 100; + window.setTimeout(() => { + const element = document.getElementsByClassName(elementId); + if (element.length > 0) { + callBack(elementId, element); + } else { + waitForElementClass(elementId, callBack); + } + }, time); +} + +function waitForElementId(elementId, callBack, time) { + time = (typeof time !== 'undefined') ? time : 100; + window.setTimeout(() => { + const element = document.getElementById(elementId); + if (element != null) { + callBack(elementId, element); + } else { + waitForElementId(elementId, callBack); + } + }, time); +} + +function waitForElementByXpath(xpath, callBack, time) { + time = (typeof time !== 'undefined') ? time : 100; + window.setTimeout(() => { + const element = getElementByXpath(xpath); + if (element) { + callBack(xpath, element); + } else { + waitForElementByXpath(xpath, callBack); + } + }, time); +} + +function getElementByXpath(xpath, contextNode) { + return document.evaluate(xpath, contextNode || document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; +} + +function createStatElement(container, title, heading) { + const statEl = document.createElement('div'); + statEl.classList.add('stats-element'); + container.appendChild(statEl); + + const statTitle = document.createElement('p'); + statTitle.classList.add('title'); + statTitle.innerText = title; + statEl.appendChild(statTitle); + + const statHeading = document.createElement('p'); + statHeading.classList.add('heading'); + statHeading.innerText = heading; + statEl.appendChild(statHeading); +} \ No newline at end of file From 0671e10fc84f2f51426f08402f713f0c652f2dcb Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Mon, 21 Aug 2023 16:54:00 -0500 Subject: [PATCH 06/55] provide stats userscript as a reference example --- plugins/2. stats/stats.js | 134 +++++++++++++++++++++++++++++++++++++ plugins/2. stats/stats.yml | 6 ++ 2 files changed, 140 insertions(+) create mode 100644 plugins/2. stats/stats.js create mode 100644 plugins/2. stats/stats.yml diff --git a/plugins/2. stats/stats.js b/plugins/2. stats/stats.js new file mode 100644 index 00000000..e8eacab3 --- /dev/null +++ b/plugins/2. stats/stats.js @@ -0,0 +1,134 @@ +function createStatElement(container, title, heading) { + const statEl = document.createElement('div'); + statEl.classList.add('stats-element'); + container.appendChild(statEl); + + const statTitle = document.createElement('p'); + statTitle.classList.add('title'); + statTitle.innerText = title; + statEl.appendChild(statTitle); + + const statHeading = document.createElement('p'); + statHeading.classList.add('heading'); + statHeading.innerText = heading; + statEl.appendChild(statHeading); +} + +async function createSceneStashIDPct(row) { + const reqData = { + "variables": { + "scene_filter": { + "stash_id": { + "value": "", + "modifier": "NOT_NULL" + } + } + }, + "query": "query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene_ids: [Int!]) {\n findScenes(filter: $filter, scene_filter: $scene_filter, scene_ids: $scene_ids) {\n count\n }\n}" + }; + const stashIdCount = (await stash.callGQL(reqData)).data.findScenes.count; + + const reqData2 = { + "variables": { + "scene_filter": {} + }, + "query": "query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene_ids: [Int!]) {\n findScenes(filter: $filter, scene_filter: $scene_filter, scene_ids: $scene_ids) {\n count\n }\n}" + }; + const totalCount = (await stash.callGQL(reqData2)).data.findScenes.count; + + createStatElement(row, (stashIdCount / totalCount * 100).toFixed(2) + '%', 'Scene StashIDs'); +} + +async function createPerformerStashIDPct(row) { + const reqData = { + "variables": { + "performer_filter": { + "stash_id": { + "value": "", + "modifier": "NOT_NULL" + } + } + }, + "query": "query FindPerformers($filter: FindFilterType, $performer_filter: PerformerFilterType) {\n findPerformers(filter: $filter, performer_filter: $performer_filter) {\n count\n }\n}" + }; + const stashIdCount = (await stash.callGQL(reqData)).data.findPerformers.count; + + const reqData2 = { + "variables": { + "performer_filter": {} + }, + "query": "query FindPerformers($filter: FindFilterType, $performer_filter: PerformerFilterType) {\n findPerformers(filter: $filter, performer_filter: $performer_filter) {\n count\n }\n}" + }; + const totalCount = (await stash.callGQL(reqData2)).data.findPerformers.count; + + createStatElement(row, (stashIdCount / totalCount * 100).toFixed(2) + '%', 'Performer StashIDs'); +} + +async function createStudioStashIDPct(row) { + const reqData = { + "variables": { + "studio_filter": { + "stash_id": { + "value": "", + "modifier": "NOT_NULL" + } + } + }, + "query": "query FindStudios($filter: FindFilterType, $studio_filter: StudioFilterType) {\n findStudios(filter: $filter, studio_filter: $studio_filter) {\n count\n }\n}" + }; + const stashIdCount = (await stash.callGQL(reqData)).data.findStudios.count; + + const reqData2 = { + "variables": { + "scene_filter": {} + }, + "query": "query FindStudios($filter: FindFilterType, $studio_filter: StudioFilterType) {\n findStudios(filter: $filter, studio_filter: $studio_filter) {\n count\n }\n}" + }; + const totalCount = (await stash.callGQL(reqData2)).data.findStudios.count; + + createStatElement(row, (stashIdCount / totalCount * 100).toFixed(2) + '%', 'Studio StashIDs'); +} + +async function createPerformerFavorites(row) { + const reqData = { + "variables": { + "performer_filter": { + "filter_favorites": true + } + }, + "query": "query FindPerformers($filter: FindFilterType, $performer_filter: PerformerFilterType) {\n findPerformers(filter: $filter, performer_filter: $performer_filter) {\n count\n }\n}" + }; + const perfCount = (await stash.callGQL(reqData)).data.findPerformers.count; + + createStatElement(row, perfCount, 'Favorite Performers'); +} + +async function createMarkersStat(row) { + const reqData = { + "variables": { + "scene_marker_filter": {} + }, + "query": "query FindSceneMarkers($filter: FindFilterType, $scene_marker_filter: SceneMarkerFilterType) {\n findSceneMarkers(filter: $filter, scene_marker_filter: $scene_marker_filter) {\n count\n }\n}" + }; + const totalCount = (await stash.callGQL(reqData)).data.findSceneMarkers.count; + + createStatElement(row, totalCount, 'Markers'); +} + +stash.addEventListener('page:stats', function() { + waitForElementByXpath("//div[contains(@class, 'container-fluid')]/div[@class='mt-5']", function(xpath, el) { + if (!document.getElementById('custom-stats-row')) { + const changelog = el.querySelector('div.changelog'); + const row = document.createElement('div'); + row.setAttribute('id', 'custom-stats-row'); + row.classList.add('col', 'col-sm-8', 'm-sm-auto', 'row', 'stats'); + el.insertBefore(row, changelog); + + createSceneStashIDPct(row); + createStudioStashIDPct(row); + createPerformerStashIDPct(row); + createPerformerFavorites(row); + createMarkersStat(row); + } + }); +}); \ No newline at end of file diff --git a/plugins/2. stats/stats.yml b/plugins/2. stats/stats.yml new file mode 100644 index 00000000..f86e6a14 --- /dev/null +++ b/plugins/2. stats/stats.yml @@ -0,0 +1,6 @@ +name: Extended Stats +description: Adds new stats to the stats page +version: 1.0 +ui: + javascript: + - stats.js From 0c5881bd48497d3c0229e1619a497490b3124f04 Mon Sep 17 00:00:00 2001 From: feederbox826 <144178721+feederbox826@users.noreply.github.com> Date: Thu, 7 Sep 2023 20:52:51 -0400 Subject: [PATCH 07/55] [timestamptrade] update - fixes #170 with JSON error handling - display debug message if no markers for scene - added explicit edit if no scenes to process --- plugins/timestampTrade/timestampTrade.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/plugins/timestampTrade/timestampTrade.py b/plugins/timestampTrade/timestampTrade.py index 633d773a..712613d9 100644 --- a/plugins/timestampTrade/timestampTrade.py +++ b/plugins/timestampTrade/timestampTrade.py @@ -8,23 +8,23 @@ import time import math - per_page = 100 request_s = requests.Session() - def processScene(s): - if len(s['stash_ids']) > 0: - for sid in s['stash_ids']: - # print('looking up markers for stash id: '+sid['stash_id']) + if len(s['stash_ids']) == 0: + log.debug('no scenes to process') + return + for sid in s['stash_ids']: + try: + log.debug('looking up markers for stash id: '+sid['stash_id']) res = requests.post('https://timestamp.trade/get-markers/' + sid['stash_id'], json=s) md = res.json() - if 'marker' in md: - log.info( - 'api returned something, for scene: ' + s['title'] + ' marker count: ' + str(len(md['marker']))) + if md.get('marker'): + log.info('api returned markers for scene: ' + s['title'] + ' marker count: ' + str(len(md['marker']))) markers = [] for m in md['marker']: -# log.debug('-- ' + m['name'] + ", " + str(m['start'] / 1000)) + # log.debug('-- ' + m['name'] + ", " + str(m['start'] / 1000)) marker = {} marker["seconds"] = m['start'] / 1000 marker["primary_tag"] = m["tag"] @@ -34,6 +34,10 @@ def processScene(s): if len(markers) > 0: log.info('Saving markers') mp.import_scene_markers(stash, markers, s['id'], 15) + else: + log.debug('api returned no markers for scene: ' + s['title']) + except json.decoder.JSONDecodeError: + log.error('api returned invalid JSON for stash id: ' + sid['stash_id']) def processAll(): @@ -95,8 +99,6 @@ def submit(): log.progress((i/count)) time.sleep(2) - - json_input = json.loads(sys.stdin.read()) FRAGMENT_SERVER = json_input["server_connection"] stash = StashInterface(FRAGMENT_SERVER) From d92c64219862655dee737b6c96db7a7029c264b8 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Sun, 17 Sep 2023 15:32:12 -0500 Subject: [PATCH 08/55] Update stashUserscriptLibrary.js --- .../stashUserscriptLibrary.js | 158 ++++++++++++++---- 1 file changed, 127 insertions(+), 31 deletions(-) diff --git a/plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js b/plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js index 2ed1d45d..9cac4202 100644 --- a/plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js +++ b/plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js @@ -1,11 +1,5 @@ const stashListener = new EventTarget(); -function concatRegexp(reg, exp) { - let flags = reg.flags + exp.flags; - flags = Array.from(new Set(flags.split(''))).join(); - return new RegExp(reg.source + exp.source, flags); -} - class Logger { constructor(enabled) { this.enabled = enabled; @@ -138,27 +132,27 @@ class Stash extends EventTarget { async getFreeOnesStats(link) { try { const doc = await fetch(link) - .then(function(response) { - // When the page is loaded convert it to text - return response.text() - }) - .then(function(html) { - // Initialize the DOM parser - var parser = new DOMParser(); - - // Parse the text - var doc = parser.parseFromString(html, "text/html"); - - // You can now even select part of that html as you would in the regular DOM - // Example: - // var docArticle = doc.querySelector('article').innerHTML; - - console.log(doc); - return doc - }) - .catch(function(err) { - console.log('Failed to fetch page: ', err); - }); + .then(function(response) { + // When the page is loaded convert it to text + return response.text() + }) + .then(function(html) { + // Initialize the DOM parser + var parser = new DOMParser(); + + // Parse the text + var doc = parser.parseFromString(html, "text/html"); + + // You can now even select part of that html as you would in the regular DOM + // Example: + // var docArticle = doc.querySelector('article').innerHTML; + + console.log(doc); + return doc + }) + .catch(function(err) { + console.log('Failed to fetch page: ', err); + }); var data = new Object(); data.rank = doc.querySelector('rank-chart-button'); @@ -858,9 +852,9 @@ class Stash extends EventTarget { let urlNode = null; let detailsNode = null; for (const sceneDetailNode of sceneDetailNodes) { - if (remoteData?.url === sceneDetailNode.innerText) { + if (sceneDetailNode.innerText.startsWith('http') && (remoteUrlNode?.href !== sceneDetailNode.innerText)) { urlNode = sceneDetailNode; - } else if (remoteData?.details === sceneDetailNode.textContent) { + } else if (!sceneDetailNode.innerText.startsWith('http')) { detailsNode = sceneDetailNode; } } @@ -869,7 +863,16 @@ class Stash extends EventTarget { const metadataNode = searchResultItem.querySelector('.scene-metadata'); const titleNode = metadataNode.querySelector('h4 .optional-field .optional-field-content'); - const dateNode = metadataNode.querySelector('h5 .optional-field .optional-field-content'); + const codeAndDateNodes = metadataNode.querySelectorAll('h5 .optional-field .optional-field-content'); + let codeNode = null; + let dateNode = null; + for (const node of codeAndDateNodes) { + if (node.textContent.includes('-')) { + dateNode = node; + } else { + codeNode = node; + } + } const entityNodes = searchResultItem.querySelectorAll('.entity-name'); let studioNode = null; @@ -923,6 +926,7 @@ class Stash extends EventTarget { detailsNode, imageNode, titleNode, + codeNode, dateNode, studioNode, performerNodes, @@ -973,6 +977,90 @@ function getElementByXpath(xpath, contextNode) { return document.evaluate(xpath, contextNode || document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; } +function createElementFromHTML(htmlString) { + const div = document.createElement('div'); + div.innerHTML = htmlString.trim(); + + // Change this to div.childNodes to support multiple top-level nodes. + return div.firstChild; +} + +function getElementByXpath(xpath, contextNode) { + return document.evaluate(xpath, contextNode || document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; +} + +function getElementsByXpath(xpath, contextNode) { + return document.evaluate(xpath, contextNode || document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); +} + +function getClosestAncestor(el, selector, stopSelector) { + let retval = null; + while (el) { + if (el.matches(selector)) { + retval = el; + break + } else if (stopSelector && el.matches(stopSelector)) { + break + } + el = el.parentElement; + } + return retval; +} + +function setNativeValue(element, value) { + const valueSetter = Object.getOwnPropertyDescriptor(element, 'value').set; + const prototype = Object.getPrototypeOf(element); + const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set; + + if (valueSetter && valueSetter !== prototypeValueSetter) { + prototypeValueSetter.call(element, value); + } else { + valueSetter.call(element, value); + } +} + +function updateTextInput(element, value) { + setNativeValue(element, value); + element.dispatchEvent(new Event('input', { + bubbles: true + })); +} + +function concatRegexp(reg, exp) { + let flags = reg.flags + exp.flags; + flags = Array.from(new Set(flags.split(''))).join(); + return new RegExp(reg.source + exp.source, flags); +} + +function sortElementChildren(node) { + const items = node.childNodes; + const itemsArr = []; + for (const i in items) { + if (items[i].nodeType == Node.ELEMENT_NODE) { // get rid of the whitespace text nodes + itemsArr.push(items[i]); + } + } + + itemsArr.sort((a, b) => { + return a.innerHTML == b.innerHTML ? + 0 : + (a.innerHTML > b.innerHTML ? 1 : -1); + }); + + for (let i = 0; i < itemsArr.length; i++) { + node.appendChild(itemsArr[i]); + } +} + +function xPathResultToArray(result) { + let node = null; + const nodes = []; + while (node = result.iterateNext()) { + nodes.push(node); + } + return nodes; +} + function createStatElement(container, title, heading) { const statEl = document.createElement('div'); statEl.classList.add('stats-element'); @@ -987,4 +1075,12 @@ function createStatElement(container, title, heading) { statHeading.classList.add('heading'); statHeading.innerText = heading; statEl.appendChild(statHeading); -} \ No newline at end of file +} + +const reloadImg = url => + fetch(url, { + cache: 'reload', + mode: 'no-cors' + }) + .then(() => document.body.querySelectorAll(`img[src='${url}']`) + .forEach(img => img.src = url)); From 75f183cba961cd06382cdd91da19459ca9f744f6 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Sun, 17 Sep 2023 15:34:47 -0500 Subject: [PATCH 09/55] Add files via upload --- .../stashBatchResultToggle.js | 306 ++++++++++++++++++ .../stashBatchResultToggle.yml | 6 + 2 files changed, 312 insertions(+) create mode 100644 plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js create mode 100644 plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.yml diff --git a/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js b/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js new file mode 100644 index 00000000..2e43b059 --- /dev/null +++ b/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js @@ -0,0 +1,306 @@ +let running = false; +const buttons = []; +let maxCount = 0; + +function resolveToggle(el) { + let button = null; + if (el?.classList.contains('optional-field-content')) { + button = el.previousElementSibling; + } else if (el?.tagName === 'SPAN' && el?.classList.contains('ml-auto')) { + button = el.querySelector('.optional-field button'); + } else if (el?.parentElement?.classList.contains('optional-field-content')) { + button = el.parentElement.previousElementSibling; + } + const state = button?.classList.contains('text-success'); + return { + button, + state + }; +} + +function toggleSearchItem(searchItem, toggleMode) { + const searchResultItem = searchItem.querySelector('li.search-result.selected-result.active'); + if (!searchResultItem) return; + + const { + urlNode, + url, + id, + data, + nameNode, + name, + queryInput, + performerNodes + } = stash.parseSearchItem(searchItem); + + const { + remoteUrlNode, + remoteId, + remoteUrl, + remoteData, + urlNode: matchUrlNode, + detailsNode, + imageNode, + titleNode, + codeNode, + dateNode, + studioNode, + performerNodes: matchPerformerNodes, + matches + } = stash.parseSearchResultItem(searchResultItem); + + const studioMatchNode = matches.find(o => o.matchType === 'studio')?.matchNode; + const performerMatchNodes = matches.filter(o => o.matchType === 'performer').map(o => o.matchNode); + + const includeTitle = document.getElementById('result-toggle-title').checked; + const includeCode = document.getElementById('result-toggle-code').checked; + const includeDate = document.getElementById('result-toggle-date').checked; + const includeCover = document.getElementById('result-toggle-cover').checked; + const includeStashID = document.getElementById('result-toggle-stashid').checked; + const includeURL = document.getElementById('result-toggle-url').checked; + const includeDetails = document.getElementById('result-toggle-details').checked; + const includeStudio = document.getElementById('result-toggle-studio').checked; + const includePerformers = document.getElementById('result-toggle-performers').checked; + + let options = []; + + options.push(['title', includeTitle, titleNode, resolveToggle(titleNode)]); + options.push(['code', includeCode, codeNode, resolveToggle(codeNode)]); + options.push(['date', includeDate, dateNode, resolveToggle(dateNode)]); + options.push(['cover', includeCover, imageNode, resolveToggle(imageNode)]); + options.push(['stashid', includeStashID, remoteUrlNode, resolveToggle(remoteUrlNode)]); + options.push(['url', includeURL, matchUrlNode, resolveToggle(matchUrlNode)]); + options.push(['details', includeDetails, detailsNode, resolveToggle(detailsNode)]); + options.push(['studio', includeStudio, studioMatchNode, resolveToggle(studioMatchNode)]); + options = options.concat(performerMatchNodes.map(o => ['performer', includePerformers, o, resolveToggle(o)])); + + for (const [optionType, optionValue, optionNode, { + button, + state + }] of options) { + let wantedState = optionValue; + if (toggleMode === 1) { + wantedState = true; + } else if (toggleMode === -1) { + wantedState = false; + } + if (optionNode && wantedState !== state) { + button.click(); + } + } +} + +function run() { + if (!running) return; + const button = buttons.pop(); + stash.setProgress((maxCount - buttons.length) / maxCount * 100); + if (button) { + const searchItem = getClosestAncestor(button, '.search-item'); + let toggleMode = 0; + if (btn === btnOn) { + toggleMode = 1; + } else if (btn === btnOff) { + toggleMode = -1; + } else if (btn === btnMixed) { + toggleMode = 0; + } + toggleSearchItem(searchItem, toggleMode); + setTimeout(run, 0); + } else { + stop(); + } +} + +const btnGroup = document.createElement('div'); +const btnGroupId = 'batch-result-toggle'; +btnGroup.setAttribute('id', btnGroupId); +btnGroup.classList.add('btn-group', 'ml-3'); + +const checkLabel = ''; +const timesLabel = ''; +const startLabel = ''; +let btn; + +const btnOffId = 'batch-result-toggle-off'; +const btnOff = document.createElement("button"); +btnOff.setAttribute("id", btnOffId); +btnOff.title = 'Result Toggle All Off'; +btnOff.classList.add('btn', 'btn-primary'); +btnOff.innerHTML = timesLabel; +btnOff.onclick = () => { + if (running) { + stop(); + } else { + btn = btnOff; + start(); + } +}; +btnGroup.appendChild(btnOff); + +const btnMixedId = 'batch-result-toggle-mixed'; +const btnMixed = document.createElement("button"); +btnMixed.setAttribute("id", btnMixedId); +btnMixed.title = 'Result Toggle All'; +btnMixed.classList.add('btn', 'btn-primary'); +btnMixed.innerHTML = startLabel; +btnMixed.onclick = () => { + if (running) { + stop(); + } else { + btn = btnMixed; + start(); + } +}; +btnGroup.appendChild(btnMixed); + +const btnOnId = 'batch-result-toggle-on'; +const btnOn = document.createElement("button"); +btnOn.setAttribute("id", btnOnId); +btnOn.title = 'Result Toggle All On'; +btnOn.classList.add('btn', 'btn-primary'); +btnOn.innerHTML = checkLabel; +btnOn.onclick = () => { + if (running) { + stop(); + } else { + btn = btnOn; + start(); + } +}; +btnGroup.appendChild(btnOn); + +function start() { + // btn.innerHTML = stopLabel; + btn.classList.remove('btn-primary'); + btn.classList.add('btn-danger'); + btnMixed.disabled = true; + btnOn.disabled = true; + btnOff.disabled = true; + btn.disabled = false; + running = true; + stash.setProgress(0); + buttons.length = 0; + for (const button of document.querySelectorAll('.btn.btn-primary')) { + if (button.innerText === 'Search') { + buttons.push(button); + } + } + maxCount = buttons.length; + run(); +} + +function stop() { + // btn.innerHTML = startLabel; + btn.classList.remove('btn-danger'); + btn.classList.add('btn-primary'); + running = false; + stash.setProgress(0); + btnMixed.disabled = false; + btnOn.disabled = false; + btnOff.disabled = false; +} + +stash.addEventListener('tagger:mutations:header', evt => { + const el = getElementByXpath("//button[text()='Scrape All']"); + if (el && !document.getElementById(btnGroupId)) { + const container = el.parentElement; + container.appendChild(btnGroup); + sortElementChildren(container); + el.classList.add('ml-3'); + } +}); + +const resultToggleConfigId = 'result-toggle-config'; + +stash.addEventListener('tagger:configuration', evt => { + const el = evt.detail; + if (!document.getElementById(resultToggleConfigId)) { + const configContainer = el.parentElement; + const resultToggleConfig = createElementFromHTML(` +
+
Result Toggle ${startLabel} Configuration
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ `); + configContainer.appendChild(resultToggleConfig); + loadSettings(); + } +}); + +async function loadSettings() { + for (const input of document.querySelectorAll(`#${resultToggleConfigId} input`)) { + input.checked = await sessionStorage.getItem(input.id, input.dataset.default === 'true'); + input.addEventListener('change', async () => { + await sessionStorage.setItem(input.id, input.checked); + }); + } +} + +stash.addEventListener('tagger:mutation:add:remoteperformer', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); +stash.addEventListener('tagger:mutation:add:remotestudio', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); +stash.addEventListener('tagger:mutation:add:local', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); +stash.addEventListener('tagger:mutation:add:container', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); +stash.addEventListener('tagger:mutation:add:subcontainer', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); + +function checkSaveButtonDisplay() { + const taggerContainer = document.querySelector('.tagger-container'); + const saveButton = getElementByXpath("//button[text()='Save']", taggerContainer); + btnGroup.style.display = saveButton ? 'inline-block' : 'none'; +} + +stash.addEventListener('tagger:mutations:searchitems', checkSaveButtonDisplay); \ No newline at end of file diff --git a/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.yml b/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.yml new file mode 100644 index 00000000..45c47492 --- /dev/null +++ b/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.yml @@ -0,0 +1,6 @@ +name: Stash Batch Result Toggle. +description: In Scene Tagger, adds button to toggle all stashdb scene match result fields. Saves clicks when you only want to save a few metadata fields. Instead of turning off every field, you batch toggle them off, then toggle on the ones you want +version: 1.0 +ui: + javascript: + - stashBatchResultToggle.js From e6a4a7c46c9d14af934a001dad6277f779008113 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Sun, 17 Sep 2023 19:05:34 -0500 Subject: [PATCH 10/55] Update stats.js --- plugins/2. stats/stats.js | 260 +++++++++++++++++++------------------- 1 file changed, 131 insertions(+), 129 deletions(-) diff --git a/plugins/2. stats/stats.js b/plugins/2. stats/stats.js index e8eacab3..6aa22c4f 100644 --- a/plugins/2. stats/stats.js +++ b/plugins/2. stats/stats.js @@ -1,134 +1,136 @@ -function createStatElement(container, title, heading) { - const statEl = document.createElement('div'); - statEl.classList.add('stats-element'); - container.appendChild(statEl); - - const statTitle = document.createElement('p'); - statTitle.classList.add('title'); - statTitle.innerText = title; - statEl.appendChild(statTitle); - - const statHeading = document.createElement('p'); - statHeading.classList.add('heading'); - statHeading.innerText = heading; - statEl.appendChild(statHeading); -} - -async function createSceneStashIDPct(row) { - const reqData = { - "variables": { - "scene_filter": { - "stash_id": { - "value": "", - "modifier": "NOT_NULL" +(function() { + function createStatElement(container, title, heading) { + const statEl = document.createElement('div'); + statEl.classList.add('stats-element'); + container.appendChild(statEl); + + const statTitle = document.createElement('p'); + statTitle.classList.add('title'); + statTitle.innerText = title; + statEl.appendChild(statTitle); + + const statHeading = document.createElement('p'); + statHeading.classList.add('heading'); + statHeading.innerText = heading; + statEl.appendChild(statHeading); + } + + async function createSceneStashIDPct(row) { + const reqData = { + "variables": { + "scene_filter": { + "stash_id": { + "value": "", + "modifier": "NOT_NULL" + } } - } - }, - "query": "query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene_ids: [Int!]) {\n findScenes(filter: $filter, scene_filter: $scene_filter, scene_ids: $scene_ids) {\n count\n }\n}" - }; - const stashIdCount = (await stash.callGQL(reqData)).data.findScenes.count; - - const reqData2 = { - "variables": { - "scene_filter": {} - }, - "query": "query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene_ids: [Int!]) {\n findScenes(filter: $filter, scene_filter: $scene_filter, scene_ids: $scene_ids) {\n count\n }\n}" - }; - const totalCount = (await stash.callGQL(reqData2)).data.findScenes.count; - - createStatElement(row, (stashIdCount / totalCount * 100).toFixed(2) + '%', 'Scene StashIDs'); -} - -async function createPerformerStashIDPct(row) { - const reqData = { - "variables": { - "performer_filter": { - "stash_id": { - "value": "", - "modifier": "NOT_NULL" + }, + "query": "query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene_ids: [Int!]) {\n findScenes(filter: $filter, scene_filter: $scene_filter, scene_ids: $scene_ids) {\n count\n }\n}" + }; + const stashIdCount = (await stash.callGQL(reqData)).data.findScenes.count; + + const reqData2 = { + "variables": { + "scene_filter": {} + }, + "query": "query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene_ids: [Int!]) {\n findScenes(filter: $filter, scene_filter: $scene_filter, scene_ids: $scene_ids) {\n count\n }\n}" + }; + const totalCount = (await stash.callGQL(reqData2)).data.findScenes.count; + + createStatElement(row, (stashIdCount / totalCount * 100).toFixed(2) + '%', 'Scene StashIDs'); + } + + async function createPerformerStashIDPct(row) { + const reqData = { + "variables": { + "performer_filter": { + "stash_id": { + "value": "", + "modifier": "NOT_NULL" + } } - } - }, - "query": "query FindPerformers($filter: FindFilterType, $performer_filter: PerformerFilterType) {\n findPerformers(filter: $filter, performer_filter: $performer_filter) {\n count\n }\n}" - }; - const stashIdCount = (await stash.callGQL(reqData)).data.findPerformers.count; - - const reqData2 = { - "variables": { - "performer_filter": {} - }, - "query": "query FindPerformers($filter: FindFilterType, $performer_filter: PerformerFilterType) {\n findPerformers(filter: $filter, performer_filter: $performer_filter) {\n count\n }\n}" - }; - const totalCount = (await stash.callGQL(reqData2)).data.findPerformers.count; - - createStatElement(row, (stashIdCount / totalCount * 100).toFixed(2) + '%', 'Performer StashIDs'); -} - -async function createStudioStashIDPct(row) { - const reqData = { - "variables": { - "studio_filter": { - "stash_id": { - "value": "", - "modifier": "NOT_NULL" + }, + "query": "query FindPerformers($filter: FindFilterType, $performer_filter: PerformerFilterType) {\n findPerformers(filter: $filter, performer_filter: $performer_filter) {\n count\n }\n}" + }; + const stashIdCount = (await stash.callGQL(reqData)).data.findPerformers.count; + + const reqData2 = { + "variables": { + "performer_filter": {} + }, + "query": "query FindPerformers($filter: FindFilterType, $performer_filter: PerformerFilterType) {\n findPerformers(filter: $filter, performer_filter: $performer_filter) {\n count\n }\n}" + }; + const totalCount = (await stash.callGQL(reqData2)).data.findPerformers.count; + + createStatElement(row, (stashIdCount / totalCount * 100).toFixed(2) + '%', 'Performer StashIDs'); + } + + async function createStudioStashIDPct(row) { + const reqData = { + "variables": { + "studio_filter": { + "stash_id": { + "value": "", + "modifier": "NOT_NULL" + } } + }, + "query": "query FindStudios($filter: FindFilterType, $studio_filter: StudioFilterType) {\n findStudios(filter: $filter, studio_filter: $studio_filter) {\n count\n }\n}" + }; + const stashIdCount = (await stash.callGQL(reqData)).data.findStudios.count; + + const reqData2 = { + "variables": { + "scene_filter": {} + }, + "query": "query FindStudios($filter: FindFilterType, $studio_filter: StudioFilterType) {\n findStudios(filter: $filter, studio_filter: $studio_filter) {\n count\n }\n}" + }; + const totalCount = (await stash.callGQL(reqData2)).data.findStudios.count; + + createStatElement(row, (stashIdCount / totalCount * 100).toFixed(2) + '%', 'Studio StashIDs'); + } + + async function createPerformerFavorites(row) { + const reqData = { + "variables": { + "performer_filter": { + "filter_favorites": true + } + }, + "query": "query FindPerformers($filter: FindFilterType, $performer_filter: PerformerFilterType) {\n findPerformers(filter: $filter, performer_filter: $performer_filter) {\n count\n }\n}" + }; + const perfCount = (await stash.callGQL(reqData)).data.findPerformers.count; + + createStatElement(row, perfCount, 'Favorite Performers'); + } + + async function createMarkersStat(row) { + const reqData = { + "variables": { + "scene_marker_filter": {} + }, + "query": "query FindSceneMarkers($filter: FindFilterType, $scene_marker_filter: SceneMarkerFilterType) {\n findSceneMarkers(filter: $filter, scene_marker_filter: $scene_marker_filter) {\n count\n }\n}" + }; + const totalCount = (await stash.callGQL(reqData)).data.findSceneMarkers.count; + + createStatElement(row, totalCount, 'Markers'); + } + + stash.addEventListener('page:stats', function() { + waitForElementByXpath("//div[contains(@class, 'container-fluid')]/div[@class='mt-5']", function(xpath, el) { + if (!document.getElementById('custom-stats-row')) { + const changelog = el.querySelector('div.changelog'); + const row = document.createElement('div'); + row.setAttribute('id', 'custom-stats-row'); + row.classList.add('col', 'col-sm-8', 'm-sm-auto', 'row', 'stats'); + el.insertBefore(row, changelog); + + createSceneStashIDPct(row); + createStudioStashIDPct(row); + createPerformerStashIDPct(row); + createPerformerFavorites(row); + createMarkersStat(row); } - }, - "query": "query FindStudios($filter: FindFilterType, $studio_filter: StudioFilterType) {\n findStudios(filter: $filter, studio_filter: $studio_filter) {\n count\n }\n}" - }; - const stashIdCount = (await stash.callGQL(reqData)).data.findStudios.count; - - const reqData2 = { - "variables": { - "scene_filter": {} - }, - "query": "query FindStudios($filter: FindFilterType, $studio_filter: StudioFilterType) {\n findStudios(filter: $filter, studio_filter: $studio_filter) {\n count\n }\n}" - }; - const totalCount = (await stash.callGQL(reqData2)).data.findStudios.count; - - createStatElement(row, (stashIdCount / totalCount * 100).toFixed(2) + '%', 'Studio StashIDs'); -} - -async function createPerformerFavorites(row) { - const reqData = { - "variables": { - "performer_filter": { - "filter_favorites": true - } - }, - "query": "query FindPerformers($filter: FindFilterType, $performer_filter: PerformerFilterType) {\n findPerformers(filter: $filter, performer_filter: $performer_filter) {\n count\n }\n}" - }; - const perfCount = (await stash.callGQL(reqData)).data.findPerformers.count; - - createStatElement(row, perfCount, 'Favorite Performers'); -} - -async function createMarkersStat(row) { - const reqData = { - "variables": { - "scene_marker_filter": {} - }, - "query": "query FindSceneMarkers($filter: FindFilterType, $scene_marker_filter: SceneMarkerFilterType) {\n findSceneMarkers(filter: $filter, scene_marker_filter: $scene_marker_filter) {\n count\n }\n}" - }; - const totalCount = (await stash.callGQL(reqData)).data.findSceneMarkers.count; - - createStatElement(row, totalCount, 'Markers'); -} - -stash.addEventListener('page:stats', function() { - waitForElementByXpath("//div[contains(@class, 'container-fluid')]/div[@class='mt-5']", function(xpath, el) { - if (!document.getElementById('custom-stats-row')) { - const changelog = el.querySelector('div.changelog'); - const row = document.createElement('div'); - row.setAttribute('id', 'custom-stats-row'); - row.classList.add('col', 'col-sm-8', 'm-sm-auto', 'row', 'stats'); - el.insertBefore(row, changelog); - - createSceneStashIDPct(row); - createStudioStashIDPct(row); - createPerformerStashIDPct(row); - createPerformerFavorites(row); - createMarkersStat(row); - } + }); }); -}); \ No newline at end of file +})(); From 3958b350cc187d5931f7e1c583c2bc7274ffc248 Mon Sep 17 00:00:00 2001 From: CJ <72030708+Teda1@users.noreply.github.com> Date: Sun, 17 Sep 2023 19:05:50 -0500 Subject: [PATCH 11/55] Update stashBatchResultToggle.js --- .../stashBatchResultToggle.js | 430 +++++++++--------- 1 file changed, 216 insertions(+), 214 deletions(-) diff --git a/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js b/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js index 2e43b059..f6c56981 100644 --- a/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js +++ b/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js @@ -1,222 +1,223 @@ -let running = false; -const buttons = []; -let maxCount = 0; +(function() { + let running = false; + const buttons = []; + let maxCount = 0; -function resolveToggle(el) { - let button = null; - if (el?.classList.contains('optional-field-content')) { - button = el.previousElementSibling; - } else if (el?.tagName === 'SPAN' && el?.classList.contains('ml-auto')) { - button = el.querySelector('.optional-field button'); - } else if (el?.parentElement?.classList.contains('optional-field-content')) { - button = el.parentElement.previousElementSibling; + function resolveToggle(el) { + let button = null; + if (el?.classList.contains('optional-field-content')) { + button = el.previousElementSibling; + } else if (el?.tagName === 'SPAN' && el?.classList.contains('ml-auto')) { + button = el.querySelector('.optional-field button'); + } else if (el?.parentElement?.classList.contains('optional-field-content')) { + button = el.parentElement.previousElementSibling; + } + const state = button?.classList.contains('text-success'); + return { + button, + state + }; } - const state = button?.classList.contains('text-success'); - return { - button, - state - }; -} -function toggleSearchItem(searchItem, toggleMode) { - const searchResultItem = searchItem.querySelector('li.search-result.selected-result.active'); - if (!searchResultItem) return; + function toggleSearchItem(searchItem, toggleMode) { + const searchResultItem = searchItem.querySelector('li.search-result.selected-result.active'); + if (!searchResultItem) return; - const { - urlNode, - url, - id, - data, - nameNode, - name, - queryInput, - performerNodes - } = stash.parseSearchItem(searchItem); + const { + urlNode, + url, + id, + data, + nameNode, + name, + queryInput, + performerNodes + } = stash.parseSearchItem(searchItem); - const { - remoteUrlNode, - remoteId, - remoteUrl, - remoteData, - urlNode: matchUrlNode, - detailsNode, - imageNode, - titleNode, - codeNode, - dateNode, - studioNode, - performerNodes: matchPerformerNodes, - matches - } = stash.parseSearchResultItem(searchResultItem); + const { + remoteUrlNode, + remoteId, + remoteUrl, + remoteData, + urlNode: matchUrlNode, + detailsNode, + imageNode, + titleNode, + codeNode, + dateNode, + studioNode, + performerNodes: matchPerformerNodes, + matches + } = stash.parseSearchResultItem(searchResultItem); - const studioMatchNode = matches.find(o => o.matchType === 'studio')?.matchNode; - const performerMatchNodes = matches.filter(o => o.matchType === 'performer').map(o => o.matchNode); + const studioMatchNode = matches.find(o => o.matchType === 'studio')?.matchNode; + const performerMatchNodes = matches.filter(o => o.matchType === 'performer').map(o => o.matchNode); - const includeTitle = document.getElementById('result-toggle-title').checked; - const includeCode = document.getElementById('result-toggle-code').checked; - const includeDate = document.getElementById('result-toggle-date').checked; - const includeCover = document.getElementById('result-toggle-cover').checked; - const includeStashID = document.getElementById('result-toggle-stashid').checked; - const includeURL = document.getElementById('result-toggle-url').checked; - const includeDetails = document.getElementById('result-toggle-details').checked; - const includeStudio = document.getElementById('result-toggle-studio').checked; - const includePerformers = document.getElementById('result-toggle-performers').checked; + const includeTitle = document.getElementById('result-toggle-title').checked; + const includeCode = document.getElementById('result-toggle-code').checked; + const includeDate = document.getElementById('result-toggle-date').checked; + const includeCover = document.getElementById('result-toggle-cover').checked; + const includeStashID = document.getElementById('result-toggle-stashid').checked; + const includeURL = document.getElementById('result-toggle-url').checked; + const includeDetails = document.getElementById('result-toggle-details').checked; + const includeStudio = document.getElementById('result-toggle-studio').checked; + const includePerformers = document.getElementById('result-toggle-performers').checked; - let options = []; + let options = []; - options.push(['title', includeTitle, titleNode, resolveToggle(titleNode)]); - options.push(['code', includeCode, codeNode, resolveToggle(codeNode)]); - options.push(['date', includeDate, dateNode, resolveToggle(dateNode)]); - options.push(['cover', includeCover, imageNode, resolveToggle(imageNode)]); - options.push(['stashid', includeStashID, remoteUrlNode, resolveToggle(remoteUrlNode)]); - options.push(['url', includeURL, matchUrlNode, resolveToggle(matchUrlNode)]); - options.push(['details', includeDetails, detailsNode, resolveToggle(detailsNode)]); - options.push(['studio', includeStudio, studioMatchNode, resolveToggle(studioMatchNode)]); - options = options.concat(performerMatchNodes.map(o => ['performer', includePerformers, o, resolveToggle(o)])); + options.push(['title', includeTitle, titleNode, resolveToggle(titleNode)]); + options.push(['code', includeCode, codeNode, resolveToggle(codeNode)]); + options.push(['date', includeDate, dateNode, resolveToggle(dateNode)]); + options.push(['cover', includeCover, imageNode, resolveToggle(imageNode)]); + options.push(['stashid', includeStashID, remoteUrlNode, resolveToggle(remoteUrlNode)]); + options.push(['url', includeURL, matchUrlNode, resolveToggle(matchUrlNode)]); + options.push(['details', includeDetails, detailsNode, resolveToggle(detailsNode)]); + options.push(['studio', includeStudio, studioMatchNode, resolveToggle(studioMatchNode)]); + options = options.concat(performerMatchNodes.map(o => ['performer', includePerformers, o, resolveToggle(o)])); - for (const [optionType, optionValue, optionNode, { - button, - state - }] of options) { - let wantedState = optionValue; - if (toggleMode === 1) { - wantedState = true; - } else if (toggleMode === -1) { - wantedState = false; - } - if (optionNode && wantedState !== state) { - button.click(); + for (const [optionType, optionValue, optionNode, { + button, + state + }] of options) { + let wantedState = optionValue; + if (toggleMode === 1) { + wantedState = true; + } else if (toggleMode === -1) { + wantedState = false; + } + if (optionNode && wantedState !== state) { + button.click(); + } } } -} -function run() { - if (!running) return; - const button = buttons.pop(); - stash.setProgress((maxCount - buttons.length) / maxCount * 100); - if (button) { - const searchItem = getClosestAncestor(button, '.search-item'); - let toggleMode = 0; - if (btn === btnOn) { - toggleMode = 1; - } else if (btn === btnOff) { - toggleMode = -1; - } else if (btn === btnMixed) { - toggleMode = 0; + function run() { + if (!running) return; + const button = buttons.pop(); + stash.setProgress((maxCount - buttons.length) / maxCount * 100); + if (button) { + const searchItem = getClosestAncestor(button, '.search-item'); + let toggleMode = 0; + if (btn === btnOn) { + toggleMode = 1; + } else if (btn === btnOff) { + toggleMode = -1; + } else if (btn === btnMixed) { + toggleMode = 0; + } + toggleSearchItem(searchItem, toggleMode); + setTimeout(run, 0); + } else { + stop(); } - toggleSearchItem(searchItem, toggleMode); - setTimeout(run, 0); - } else { - stop(); } -} -const btnGroup = document.createElement('div'); -const btnGroupId = 'batch-result-toggle'; -btnGroup.setAttribute('id', btnGroupId); -btnGroup.classList.add('btn-group', 'ml-3'); + const btnGroup = document.createElement('div'); + const btnGroupId = 'batch-result-toggle'; + btnGroup.setAttribute('id', btnGroupId); + btnGroup.classList.add('btn-group', 'ml-3'); -const checkLabel = ''; -const timesLabel = ''; -const startLabel = ''; -let btn; + const checkLabel = ''; + const timesLabel = ''; + const startLabel = ''; + let btn; -const btnOffId = 'batch-result-toggle-off'; -const btnOff = document.createElement("button"); -btnOff.setAttribute("id", btnOffId); -btnOff.title = 'Result Toggle All Off'; -btnOff.classList.add('btn', 'btn-primary'); -btnOff.innerHTML = timesLabel; -btnOff.onclick = () => { - if (running) { - stop(); - } else { - btn = btnOff; - start(); - } -}; -btnGroup.appendChild(btnOff); + const btnOffId = 'batch-result-toggle-off'; + const btnOff = document.createElement("button"); + btnOff.setAttribute("id", btnOffId); + btnOff.title = 'Result Toggle All Off'; + btnOff.classList.add('btn', 'btn-primary'); + btnOff.innerHTML = timesLabel; + btnOff.onclick = () => { + if (running) { + stop(); + } else { + btn = btnOff; + start(); + } + }; + btnGroup.appendChild(btnOff); -const btnMixedId = 'batch-result-toggle-mixed'; -const btnMixed = document.createElement("button"); -btnMixed.setAttribute("id", btnMixedId); -btnMixed.title = 'Result Toggle All'; -btnMixed.classList.add('btn', 'btn-primary'); -btnMixed.innerHTML = startLabel; -btnMixed.onclick = () => { - if (running) { - stop(); - } else { - btn = btnMixed; - start(); - } -}; -btnGroup.appendChild(btnMixed); + const btnMixedId = 'batch-result-toggle-mixed'; + const btnMixed = document.createElement("button"); + btnMixed.setAttribute("id", btnMixedId); + btnMixed.title = 'Result Toggle All'; + btnMixed.classList.add('btn', 'btn-primary'); + btnMixed.innerHTML = startLabel; + btnMixed.onclick = () => { + if (running) { + stop(); + } else { + btn = btnMixed; + start(); + } + }; + btnGroup.appendChild(btnMixed); -const btnOnId = 'batch-result-toggle-on'; -const btnOn = document.createElement("button"); -btnOn.setAttribute("id", btnOnId); -btnOn.title = 'Result Toggle All On'; -btnOn.classList.add('btn', 'btn-primary'); -btnOn.innerHTML = checkLabel; -btnOn.onclick = () => { - if (running) { - stop(); - } else { - btn = btnOn; - start(); - } -}; -btnGroup.appendChild(btnOn); + const btnOnId = 'batch-result-toggle-on'; + const btnOn = document.createElement("button"); + btnOn.setAttribute("id", btnOnId); + btnOn.title = 'Result Toggle All On'; + btnOn.classList.add('btn', 'btn-primary'); + btnOn.innerHTML = checkLabel; + btnOn.onclick = () => { + if (running) { + stop(); + } else { + btn = btnOn; + start(); + } + }; + btnGroup.appendChild(btnOn); -function start() { - // btn.innerHTML = stopLabel; - btn.classList.remove('btn-primary'); - btn.classList.add('btn-danger'); - btnMixed.disabled = true; - btnOn.disabled = true; - btnOff.disabled = true; - btn.disabled = false; - running = true; - stash.setProgress(0); - buttons.length = 0; - for (const button of document.querySelectorAll('.btn.btn-primary')) { - if (button.innerText === 'Search') { - buttons.push(button); + function start() { + // btn.innerHTML = stopLabel; + btn.classList.remove('btn-primary'); + btn.classList.add('btn-danger'); + btnMixed.disabled = true; + btnOn.disabled = true; + btnOff.disabled = true; + btn.disabled = false; + running = true; + stash.setProgress(0); + buttons.length = 0; + for (const button of document.querySelectorAll('.btn.btn-primary')) { + if (button.innerText === 'Search') { + buttons.push(button); + } } + maxCount = buttons.length; + run(); } - maxCount = buttons.length; - run(); -} - -function stop() { - // btn.innerHTML = startLabel; - btn.classList.remove('btn-danger'); - btn.classList.add('btn-primary'); - running = false; - stash.setProgress(0); - btnMixed.disabled = false; - btnOn.disabled = false; - btnOff.disabled = false; -} -stash.addEventListener('tagger:mutations:header', evt => { - const el = getElementByXpath("//button[text()='Scrape All']"); - if (el && !document.getElementById(btnGroupId)) { - const container = el.parentElement; - container.appendChild(btnGroup); - sortElementChildren(container); - el.classList.add('ml-3'); + function stop() { + // btn.innerHTML = startLabel; + btn.classList.remove('btn-danger'); + btn.classList.add('btn-primary'); + running = false; + stash.setProgress(0); + btnMixed.disabled = false; + btnOn.disabled = false; + btnOff.disabled = false; } -}); -const resultToggleConfigId = 'result-toggle-config'; + stash.addEventListener('tagger:mutations:header', evt => { + const el = getElementByXpath("//button[text()='Scrape All']"); + if (el && !document.getElementById(btnGroupId)) { + const container = el.parentElement; + container.appendChild(btnGroup); + sortElementChildren(container); + el.classList.add('ml-3'); + } + }); -stash.addEventListener('tagger:configuration', evt => { - const el = evt.detail; - if (!document.getElementById(resultToggleConfigId)) { - const configContainer = el.parentElement; - const resultToggleConfig = createElementFromHTML(` + const resultToggleConfigId = 'result-toggle-config'; + + stash.addEventListener('tagger:configuration', evt => { + const el = evt.detail; + if (!document.getElementById(resultToggleConfigId)) { + const configContainer = el.parentElement; + const resultToggleConfig = createElementFromHTML(`
Result Toggle ${startLabel} Configuration
@@ -277,30 +278,31 @@ stash.addEventListener('tagger:configuration', evt => {
`); - configContainer.appendChild(resultToggleConfig); - loadSettings(); - } -}); + configContainer.appendChild(resultToggleConfig); + loadSettings(); + } + }); -async function loadSettings() { - for (const input of document.querySelectorAll(`#${resultToggleConfigId} input`)) { - input.checked = await sessionStorage.getItem(input.id, input.dataset.default === 'true'); - input.addEventListener('change', async () => { - await sessionStorage.setItem(input.id, input.checked); - }); + async function loadSettings() { + for (const input of document.querySelectorAll(`#${resultToggleConfigId} input`)) { + input.checked = await sessionStorage.getItem(input.id, input.dataset.default === 'true'); + input.addEventListener('change', async () => { + await sessionStorage.setItem(input.id, input.checked); + }); + } } -} -stash.addEventListener('tagger:mutation:add:remoteperformer', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); -stash.addEventListener('tagger:mutation:add:remotestudio', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); -stash.addEventListener('tagger:mutation:add:local', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); -stash.addEventListener('tagger:mutation:add:container', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); -stash.addEventListener('tagger:mutation:add:subcontainer', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); + stash.addEventListener('tagger:mutation:add:remoteperformer', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); + stash.addEventListener('tagger:mutation:add:remotestudio', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); + stash.addEventListener('tagger:mutation:add:local', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); + stash.addEventListener('tagger:mutation:add:container', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); + stash.addEventListener('tagger:mutation:add:subcontainer', evt => toggleSearchItem(getClosestAncestor(evt.detail.node, '.search-item'), 0)); -function checkSaveButtonDisplay() { - const taggerContainer = document.querySelector('.tagger-container'); - const saveButton = getElementByXpath("//button[text()='Save']", taggerContainer); - btnGroup.style.display = saveButton ? 'inline-block' : 'none'; -} + function checkSaveButtonDisplay() { + const taggerContainer = document.querySelector('.tagger-container'); + const saveButton = getElementByXpath("//button[text()='Save']", taggerContainer); + btnGroup.style.display = saveButton ? 'inline-block' : 'none'; + } -stash.addEventListener('tagger:mutations:searchitems', checkSaveButtonDisplay); \ No newline at end of file + stash.addEventListener('tagger:mutations:searchitems', checkSaveButtonDisplay); +})(); From a115465d222ebec1dc2d73745b9a5323edb09bbf Mon Sep 17 00:00:00 2001 From: feederbox826 <144178721+feederbox826@users.noreply.github.com> Date: Sat, 23 Sep 2023 23:26:17 -0400 Subject: [PATCH 12/55] [timestamp] add [Skip Sync] and [Skip Submit] tags built on https://github.com/stashapp/CommunityScripts/pull/172 --- plugins/timestampTrade/timestampTrade.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/timestampTrade/timestampTrade.py b/plugins/timestampTrade/timestampTrade.py index 712613d9..941a05fd 100644 --- a/plugins/timestampTrade/timestampTrade.py +++ b/plugins/timestampTrade/timestampTrade.py @@ -42,7 +42,8 @@ def processScene(s): def processAll(): log.info('Getting scene count') - count=stash.find_scenes(f={"stash_id":{"value":"","modifier":"NOT_NULL"},"has_markers":"false"},filter={"per_page": 1},get_count=True)[0] + skip_sync_tag_id = stash.find_tag('[Timestamp: Skip Sync]', create=True).get("id") + count=stash.find_scenes(f={"stash_id":{"value":"","modifier":"NOT_NULL"},"has_markers":"false","tags":{"depth":0,"excludes":[skip_sync_tag_id],"modifier":"INCLUDES_ALL","value":[]}},filter={"per_page": 1},get_count=True)[0] log.info(str(count)+' scenes to submit.') i=0 for r in range(1,int(count/per_page)+1): @@ -54,6 +55,7 @@ def processAll(): log.progress((i/count)) time.sleep(2) + def submit(): scene_fgmt = """title details @@ -87,7 +89,8 @@ def submit(): name } }""" - count = stash.find_scenes(f={"has_markers": "true"}, filter={"per_page": 1}, get_count=True)[0] + skip_submit_tag_id = stash.find_tag('[Timestamp: Skip Submit]', create=True).get("id") + count = stash.find_scenes(f={"has_markers": "true","tags":{"depth":0,"excludes":[skip_sync_tag_id],"modifier":"INCLUDES_ALL","value":[]}}, filter={"per_page": 1}, get_count=True)[0] i=0 for r in range(1, math.ceil(count/per_page) + 1): log.info('submitting scenes: %s - %s %0.1f%%' % ((r - 1) * per_page,r * per_page,(i/count)*100,)) From c1fd3c655c618daff99feba76376c31b6f73c6d9 Mon Sep 17 00:00:00 2001 From: feederbox826 <144178721+feederbox826@users.noreply.github.com> Date: Sun, 24 Sep 2023 00:18:47 -0400 Subject: [PATCH 13/55] [timestamp] respect skip sync during updates Add if structure to skip updating scenes [skip sync] tag --- plugins/timestampTrade/timestampTrade.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/timestampTrade/timestampTrade.py b/plugins/timestampTrade/timestampTrade.py index 941a05fd..13092caf 100644 --- a/plugins/timestampTrade/timestampTrade.py +++ b/plugins/timestampTrade/timestampTrade.py @@ -15,8 +15,12 @@ def processScene(s): if len(s['stash_ids']) == 0: log.debug('no scenes to process') return + skip_sync_tag_id = stash.find_tag('[Timestamp: Skip Sync]', create=True).get("id") for sid in s['stash_ids']: try: + if any(tag['id'] == str(skip_sync_tag_id) for tag in s['tags']): + log.debug('scene has skip sync tag') + return log.debug('looking up markers for stash id: '+sid['stash_id']) res = requests.post('https://timestamp.trade/get-markers/' + sid['stash_id'], json=s) md = res.json() @@ -55,7 +59,6 @@ def processAll(): log.progress((i/count)) time.sleep(2) - def submit(): scene_fgmt = """title details From e057fe72fd8dbf4888b48f931095dcf7dcb9a0f0 Mon Sep 17 00:00:00 2001 From: feederbox826 <144178721+feederbox826@users.noreply.github.com> Date: Sun, 24 Sep 2023 21:45:44 +0000 Subject: [PATCH 14/55] Add dupeMarker --- plugins/dupeMarker/README.md | 8 ++++ plugins/dupeMarker/dupeMarker.py | 69 +++++++++++++++++++++++++++++ plugins/dupeMarker/dupeMarker.yml | 13 ++++++ plugins/dupeMarker/requirements.txt | 1 + 4 files changed, 91 insertions(+) create mode 100644 plugins/dupeMarker/README.md create mode 100644 plugins/dupeMarker/dupeMarker.py create mode 100644 plugins/dupeMarker/dupeMarker.yml create mode 100644 plugins/dupeMarker/requirements.txt diff --git a/plugins/dupeMarker/README.md b/plugins/dupeMarker/README.md new file mode 100644 index 00000000..47d53794 --- /dev/null +++ b/plugins/dupeMarker/README.md @@ -0,0 +1,8 @@ +Marks duplicate markers with a tag: `[Marker: Duplicate]` + +Tasks -> Search for duplicate markers + +It will add the tag to any markers that have an **exact** match for title, time **and** primary tag. +It will only add to existing markers, it is up to the user to go to the tag and navigate to the scene where the duplicates will be highlighted with the tag. + +(it's technically a Dupe Marker Marker) \ No newline at end of file diff --git a/plugins/dupeMarker/dupeMarker.py b/plugins/dupeMarker/dupeMarker.py new file mode 100644 index 00000000..d90c6720 --- /dev/null +++ b/plugins/dupeMarker/dupeMarker.py @@ -0,0 +1,69 @@ +import json +import sys +import re +import datetime as dt +import stashapi.log as log +from stashapi.tools import human_bytes +from stashapi.stash_types import PhashDistance +from stashapi.stashapp import StashInterface + +FRAGMENT = json.loads(sys.stdin.read()) +MODE = FRAGMENT['args']['mode'] +stash = StashInterface(FRAGMENT["server_connection"]) +dupe_marker_tag = stash.find_tag('[Marker: Duplicate]', create=True).get("id") + +def findScenesWithMarkers(): + totalDupes = 0 + scenes = stash.find_scenes(f={"has_markers": "true"},fragment="id") + for scene in scenes: + totalDupes += checkScene(scene) + log.info("Found %d duplicate markers across %d scenes" % (totalDupes, len(scenes))) + +def addMarkerTag(marker): + query = """ + mutation SceneMarkerUpdate($input:SceneMarkerUpdateInput!) { + sceneMarkerUpdate(input: $input) { + id + } + } + """ + oldTags = [tag["id"] for tag in marker["tags"]] + if dupe_marker_tag in oldTags: + return + oldTags.append(dupe_marker_tag) + newMarker = { + "id": marker["id"], + "tag_ids": oldTags + } + stash._callGraphQL(query, {"input": newMarker }) + #stash.update_scene_marker(newMarker, "id") + +def checkScene(scene): + seen = set() + dupes = [] + markers = stash.find_scene_markers(scene['id']) + # find duplicate pairs + for marker in markers: + sortidx = ";".join([ + str(marker["title"]), + str(marker["seconds"]), + str(marker["primary_tag"]["id"]) + ]) + if sortidx not in seen: + seen.add(sortidx) + else: + dupes.append(marker) + # add tag + if dupes: + log.debug("Found %d duplicate markers in scene %s" % (len(dupes), scene['id'])) + for dupe in dupes: + addMarkerTag(dupe) + return len(dupes) + +def main(): + if MODE == "search": + findScenesWithMarkers() + log.exit("Plugin exited normally.") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/plugins/dupeMarker/dupeMarker.yml b/plugins/dupeMarker/dupeMarker.yml new file mode 100644 index 00000000..d043b972 --- /dev/null +++ b/plugins/dupeMarker/dupeMarker.yml @@ -0,0 +1,13 @@ +name: Dupe Marker Detector +description: Finds and marks duplicate markers +version: 0.1 +url: https://github.com/stashapp/CommunityScripts/ +exec: + - python + - "{pluginDir}/dupeMarker.py" +interface: raw +tasks: + - name: 'Search' + description: Search for duplicate markers + defaultArgs: + mode: search \ No newline at end of file diff --git a/plugins/dupeMarker/requirements.txt b/plugins/dupeMarker/requirements.txt new file mode 100644 index 00000000..5bda5826 --- /dev/null +++ b/plugins/dupeMarker/requirements.txt @@ -0,0 +1 @@ +stashapp-tools \ No newline at end of file From 93f3ab18084ee4703089195b3baf79a02d5d0130 Mon Sep 17 00:00:00 2001 From: DuctTape42 Date: Tue, 26 Sep 2023 17:21:33 -0700 Subject: [PATCH 15/55] Initial commit of stash file watcher --- scripts/stash-watcher/.gitignore | 161 +++++++++++++++++++++++++ scripts/stash-watcher/Dockerfile | 15 +++ scripts/stash-watcher/README.md | 2 + scripts/stash-watcher/config.toml | 0 scripts/stash-watcher/defaults.toml | 39 ++++++ scripts/stash-watcher/requirements.txt | 4 + scripts/stash-watcher/watcher.py | 160 ++++++++++++++++++++++++ 7 files changed, 381 insertions(+) create mode 100644 scripts/stash-watcher/.gitignore create mode 100644 scripts/stash-watcher/Dockerfile create mode 100644 scripts/stash-watcher/README.md create mode 100644 scripts/stash-watcher/config.toml create mode 100644 scripts/stash-watcher/defaults.toml create mode 100644 scripts/stash-watcher/requirements.txt create mode 100644 scripts/stash-watcher/watcher.py diff --git a/scripts/stash-watcher/.gitignore b/scripts/stash-watcher/.gitignore new file mode 100644 index 00000000..af7d4c93 --- /dev/null +++ b/scripts/stash-watcher/.gitignore @@ -0,0 +1,161 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +config.toml diff --git a/scripts/stash-watcher/Dockerfile b/scripts/stash-watcher/Dockerfile new file mode 100644 index 00000000..8ccda1c5 --- /dev/null +++ b/scripts/stash-watcher/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11.5-alpine3.18 + +WORKDIR /usr/src/app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +#Create an empty config file so that we can just use the defaults. This file can be mounted if it needs to be +#modified +RUN touch /config.toml + +#Apparently using -u causes the logs to output immediately +CMD [ "python", "-u", "./watcher.py", "/config.toml" ] diff --git a/scripts/stash-watcher/README.md b/scripts/stash-watcher/README.md new file mode 100644 index 00000000..c3aaa873 --- /dev/null +++ b/scripts/stash-watcher/README.md @@ -0,0 +1,2 @@ +# stash-inotify +inotify service for stash diff --git a/scripts/stash-watcher/config.toml b/scripts/stash-watcher/config.toml new file mode 100644 index 00000000..e69de29b diff --git a/scripts/stash-watcher/defaults.toml b/scripts/stash-watcher/defaults.toml new file mode 100644 index 00000000..b938eac6 --- /dev/null +++ b/scripts/stash-watcher/defaults.toml @@ -0,0 +1,39 @@ +#This is the information about your stash instance +[Host] +#The scheme (either http or https) +Scheme = http +#The full hostname for your stash instance. If you're running in docker you might want the +#service name and not localhost here. +Host = localhost +#The port number for your stash instance +Port = 9999 +#The api key, if your stash instance is password protected +ApiKey = + +#Configuration for the listener itself +[Config] +#A comma separated list of paths to watch. +Paths = /data +#The minimum time to wait between triggering scans +Cooldown = 300 +#A list of file extensions to watch. If this is omitted, it uses the extensions that are defined +#in your Stash library (for videos, images, and galleries) +Extensions = + +#Options for the Stash Scan. Stash defaults to everything disabled, so this is the default +#Generate options that match up with what we can do in Scan +[ScanOptions] +#"Generate scene covers" from the UI +Covers=true +#"Generate previews" from the UI +Previews=true +#"Generate animated image previews" from the UI +ImagePreviews=false +#"Generate scrubber sprites" from the UI +Sprites=false +#"Generate perceptual hashes" from the UI +Phashes=true +#"Generate thumbnails for images" from the UI +Thumbnails=true +#"Generate previews for image clips" from the UI +ClipPreviews=false diff --git a/scripts/stash-watcher/requirements.txt b/scripts/stash-watcher/requirements.txt new file mode 100644 index 00000000..66283a1a --- /dev/null +++ b/scripts/stash-watcher/requirements.txt @@ -0,0 +1,4 @@ +argparse +gql +stashapp-tools +watchdog diff --git a/scripts/stash-watcher/watcher.py b/scripts/stash-watcher/watcher.py new file mode 100644 index 00000000..1a0065ad --- /dev/null +++ b/scripts/stash-watcher/watcher.py @@ -0,0 +1,160 @@ +#!/usr/bin/python -w +import argparse +import configparser +import time +import os +from threading import Lock, Condition +from watchdog.observers import Observer +from watchdog.events import PatternMatchingEventHandler +from stashapi.stashapp import StashInterface +import logging + +#Setup logger +logger = logging.getLogger("stash-watcher") +logger.setLevel(logging.INFO) +ch = logging.StreamHandler() +ch.setLevel(logging.INFO) +ch.setFormatter(logging.Formatter("%(asctime)s %(message)s")) +logger.addHandler(ch) + +#This signals that we should +shouldUpdate = False +mutex = Lock() +signal = Condition(mutex) + +modifiedFiles = {} + +def log(msg): + logger.info(msg) + +def debug(msg): + logger.debug(msg) + +def handleEvent(event): + global shouldUpdate + debug("========EVENT========") + debug(str(event)) + #log(modifiedFiles) + #Record if the file was modified. When a file is closed, see if it was modified. If so, trigger + shouldTrigger = False + if event.is_directory == False: + if event.event_type == "modified": + modifiedFiles[event.src_path] = 1 + #These are for files being copied into the target + elif event.event_type == "closed": + if event.src_path in modifiedFiles: + del modifiedFiles[event.src_path] + shouldTrigger = True + #For download managers and the like that write to a temporary file and then move to the destination (real) + #path. Note that this actually triggers if the destination is in the watched location, and not just if it's + #moved out of a watched directory + elif event.event_type == "moved": + shouldTrigger = True + + #Trigger the update + if shouldTrigger: + debug("Triggering updates") + with mutex: + shouldUpdate = True + signal.notify() + + +def main(stash, scanFlags, paths, extensions, timeout): + global shouldUpdate + + if len(extensions) == 1 and extensions[0] == "*": + patterns = ["*"] + else: + patterns = list(map(lambda x : "*." + x, extensions)) + eventHandler = PatternMatchingEventHandler(patterns, None, False, True) + eventHandler.on_any_event = handleEvent + observer = Observer() + for path in paths: + observer.schedule(eventHandler, path, recursive=True) + observer.start() + try: + while True: + with mutex: + while not shouldUpdate: + signal.wait() + shouldUpdate = False + log("Triggering stash scan") + stash.metadata_scan(flags = scanFlags) + log("Sleeping for " + str(timeout) + " seconds") + time.sleep(timeout) + except KeyboardInterrupt: + observer.stop() + observer.join() + +def listConverter(item): + debug("listConverter(" + str(item) + ")") + if not item: + return None + listItems = [i.strip() for i in item.split(',')] + if not listItems or (len(listItems) == 1 and not listItems[0]): + return None + return listItems + +def makeArgParser(): + parser = argparse.ArgumentParser(description='Stash file watcher') + parser.add_argument('config_path', nargs=1, help='Config file path (toml)') + return parser + +def parseConfig(path): + config = configparser.ConfigParser(converters={'list': listConverter }) + + + #Load the defaults first + defaults_path = os.path.join(os.path.dirname('__file__'), 'defaults.toml') + config.read(defaults_path) + + #Now read the user config + config.read(path) + + return config + +if __name__ == '__main__': + #Parse the arguments + parser = makeArgParser() + args = parser.parse_args() + configPath = args.config_path + config = parseConfig(configPath) + + #Set up Stash + stashArgs = { + "scheme": config["Host"]["Scheme"], + "host": config["Host"]["Host"], + "port": config["Host"]["Port"] + } + + if config["Host"]["ApiKey"]: + stashArgs["ApiKey"] = config["Host"]["ApiKey"] + + stash = StashInterface(stashArgs) + + #And now the flags for the scan + scanFlags = { + "scanGenerateCovers": config["ScanOptions"].getboolean("Covers"), + "scanGeneratePreviews": config["ScanOptions"].getboolean("Previews"), + "scanGenerateImagePreviews": config["ScanOptions"].getboolean("ImagePreviews"), + "scanGenerateSprites": config["ScanOptions"].getboolean("Sprites"), + "scanGeneratePhashes": config["ScanOptions"].getboolean("Phashes"), + "scanGenerateThumbnails": config["ScanOptions"].getboolean("Thumbnails"), + "scanGenerateClipPreviews": config["ScanOptions"].getboolean("ClipPreviews") + } + + paths = config.getlist("Config", "Paths") + timeout = config["Config"].getint("Cooldown") + + #If the extensions are in the config, use them. Otherwise pull them from stash. + extensions = config.getlist('Config', 'Extensions') + if not extensions: + stashConfig = stash.graphql_configuration() + extensions = stashConfig['general']['videoExtensions'] + stashConfig['general']['imageExtensions'] + stashConfig['general']['galleryExtensions'] + + main(stash, scanFlags, paths, extensions, timeout) + + + + + From aa11a295ea09e8fb8ff0dab342639cd9dc342f0a Mon Sep 17 00:00:00 2001 From: DuctTape42 Date: Tue, 26 Sep 2023 18:00:17 -0700 Subject: [PATCH 16/55] Remove empty config.toml file --- scripts/stash-watcher/config.toml | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 scripts/stash-watcher/config.toml diff --git a/scripts/stash-watcher/config.toml b/scripts/stash-watcher/config.toml deleted file mode 100644 index e69de29b..00000000 From 5cecd9d1dc67fb5d944898b62565b75383bd9c4f Mon Sep 17 00:00:00 2001 From: DuctTape42 Date: Tue, 26 Sep 2023 18:04:33 -0700 Subject: [PATCH 17/55] Add a template config.toml --- scripts/stash-watcher/.gitignore | 1 - scripts/stash-watcher/config.toml | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 scripts/stash-watcher/config.toml diff --git a/scripts/stash-watcher/.gitignore b/scripts/stash-watcher/.gitignore index af7d4c93..68bc17f9 100644 --- a/scripts/stash-watcher/.gitignore +++ b/scripts/stash-watcher/.gitignore @@ -158,4 +158,3 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ -config.toml diff --git a/scripts/stash-watcher/config.toml b/scripts/stash-watcher/config.toml new file mode 100644 index 00000000..d478dbf3 --- /dev/null +++ b/scripts/stash-watcher/config.toml @@ -0,0 +1,16 @@ +#This is the information about your stash instance +[Host] +#The scheme (either http or https) +Scheme = http +#The full hostname for your stash instance. If you're running in docker you might want the +#service name and not localhost here. +Host = localhost +#The port number for your stash instance +Port = 9999 +#The api key, if your stash instance is password protected +ApiKey = + +#Configuration for the listener itself +[Config] +#A comma separated list of paths to watch. +Paths = /data From 29ab7190be33ecf1c7e1d686dae4dadbf82fb538 Mon Sep 17 00:00:00 2001 From: DuctTape42 <110079000+DuctTape42@users.noreply.github.com> Date: Tue, 26 Sep 2023 18:23:48 -0700 Subject: [PATCH 18/55] Update README.md Added readme with project description and execution instructions --- scripts/stash-watcher/README.md | 57 +++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/scripts/stash-watcher/README.md b/scripts/stash-watcher/README.md index c3aaa873..976417c8 100644 --- a/scripts/stash-watcher/README.md +++ b/scripts/stash-watcher/README.md @@ -1,2 +1,55 @@ -# stash-inotify -inotify service for stash +# Stash Watcher +Stash Watcher is a service that watches your Stash library directories for changes and then triggers a Metadata Scan when new files are added to those directories. It then waits a period of time before triggering another scan to keep Stash from constantly scanning if you're making many changes. Note that updates are watched during that window; the update is merely delayed. + +## Configuration {#configuration} +Modify a [config.toml](config.toml) for your environment. The defaults match the Stash docker defaults, so they may work for you. You are likely to have to update `Paths` and possibly `ApiKey`. Check out [default.toml](default.toml) for all configurable options. You can configure: +* Url (host, domain, port) +* Api Key (if your Stash is password protected) +* Paths +* Timeout - the minimum time between Metadata Scans +* Scan options - The options for the Metadata Scan + +## Running Stash Watcher +You can run Stash Watcher directly from the [command line](#run-python) or from inside [docker](#run-docker). + +### Running directly with python {#run-python} +The directs below are for linux, but they should work on other operating systems. +#### Step 0: Create a Virtual Environment (optional, but recommended) +``` +python -m venv venv +. venv/bin/activate +``` +#### Step 1: Install dependencies +``` +pip install -r requirements.txt +``` +#### Step 2: Create/Modify Configuration +Following the directions in [Configuration](#configuration), modify [config.toml](config.toml) if necessary. + +#### Step 3: Execute +``` +python watcher.py path_to_config.toml +``` +That's it. Now when you make changes to watched directories, Stash Watcher will make an API call to trigger a metadata scan. + +### Running with docker {#run-docker} +There is currently no published docker image, so you'll have to build it yourself. The easiest way to do this is with docker compose: +``` +version: "3.4" +services: + stash-watcher: + container_name: stash-watcher + build: + volumes: + #This is only required if you have to modify config.toml (if the defaults are fine you don't have to map this file) + - ./config.toml:/config.toml:ro + #This is the path to your stash content. If you have multiple paths, map them here + - /stash:/data:ro + restart: unless-stopped +``` + +Then you can run +``` +docker compose up -d --build +``` +To start the watcher. From 1a38b753b691d143c8ef2f5b4e2207554a3b81cb Mon Sep 17 00:00:00 2001 From: DuctTape42 <110079000+DuctTape42@users.noreply.github.com> Date: Tue, 26 Sep 2023 19:31:59 -0700 Subject: [PATCH 19/55] Fix anchor tags in readme --- scripts/stash-watcher/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/stash-watcher/README.md b/scripts/stash-watcher/README.md index 976417c8..17b414b7 100644 --- a/scripts/stash-watcher/README.md +++ b/scripts/stash-watcher/README.md @@ -1,7 +1,7 @@ # Stash Watcher Stash Watcher is a service that watches your Stash library directories for changes and then triggers a Metadata Scan when new files are added to those directories. It then waits a period of time before triggering another scan to keep Stash from constantly scanning if you're making many changes. Note that updates are watched during that window; the update is merely delayed. -## Configuration {#configuration} +## Configuration Modify a [config.toml](config.toml) for your environment. The defaults match the Stash docker defaults, so they may work for you. You are likely to have to update `Paths` and possibly `ApiKey`. Check out [default.toml](default.toml) for all configurable options. You can configure: * Url (host, domain, port) * Api Key (if your Stash is password protected) @@ -10,9 +10,9 @@ Modify a [config.toml](config.toml) for your environment. The defaults match th * Scan options - The options for the Metadata Scan ## Running Stash Watcher -You can run Stash Watcher directly from the [command line](#run-python) or from inside [docker](#run-docker). +You can run Stash Watcher directly from the [command line](#running-directly-with-python) or from inside [docker](#running-with-docker). -### Running directly with python {#run-python} +### Running directly with python The directs below are for linux, but they should work on other operating systems. #### Step 0: Create a Virtual Environment (optional, but recommended) ``` @@ -32,7 +32,7 @@ python watcher.py path_to_config.toml ``` That's it. Now when you make changes to watched directories, Stash Watcher will make an API call to trigger a metadata scan. -### Running with docker {#run-docker} +### Running with docker There is currently no published docker image, so you'll have to build it yourself. The easiest way to do this is with docker compose: ``` version: "3.4" From c722cc65cecca3f0b495362a97ec7a307042394a Mon Sep 17 00:00:00 2001 From: DuctTape42 Date: Thu, 28 Sep 2023 08:04:50 -0700 Subject: [PATCH 20/55] Remove unused dependency from requirements.txt --- scripts/stash-watcher/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/stash-watcher/requirements.txt b/scripts/stash-watcher/requirements.txt index 66283a1a..d1fe8a2a 100644 --- a/scripts/stash-watcher/requirements.txt +++ b/scripts/stash-watcher/requirements.txt @@ -1,4 +1,3 @@ argparse -gql stashapp-tools watchdog From 161b4fd7061aef285ba4c7eb699875c7f586ecf4 Mon Sep 17 00:00:00 2001 From: DuctTape42 Date: Thu, 28 Sep 2023 08:06:04 -0700 Subject: [PATCH 21/55] Watcher is now aware of which platform you're on. Adds heuristics for Windows, Linux, and a generic Poller. Mac OS just mirrors Linux (hopefully that works). Windows tests to see if the file is openable (works on local files to see if they're still in use before triggering an update) --- scripts/stash-watcher/defaults.toml | 9 +++ scripts/stash-watcher/watcher.py | 91 ++++++++++++++++++++++++++++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/scripts/stash-watcher/defaults.toml b/scripts/stash-watcher/defaults.toml index b938eac6..a110845d 100644 --- a/scripts/stash-watcher/defaults.toml +++ b/scripts/stash-watcher/defaults.toml @@ -19,6 +19,15 @@ Cooldown = 300 #A list of file extensions to watch. If this is omitted, it uses the extensions that are defined #in your Stash library (for videos, images, and galleries) Extensions = +#If this is set to a non-zero numeric value, this forces the use of polling to +#determine file system changes. If it is left blank, then the OS appropriate +#mechanism is used. This is much less efficient than the OS mechanism, so it +#should be used with care. The docs claim that this is required to watch SMB +#shares, though in my testing I could watch them on Windows with the regular +#WindowsApiObserver +PollInterval= +#This enables debug logging +Debug= #Options for the Stash Scan. Stash defaults to everything disabled, so this is the default #Generate options that match up with what we can do in Scan diff --git a/scripts/stash-watcher/watcher.py b/scripts/stash-watcher/watcher.py index 1a0065ad..eace46ca 100644 --- a/scripts/stash-watcher/watcher.py +++ b/scripts/stash-watcher/watcher.py @@ -5,9 +5,15 @@ import os from threading import Lock, Condition from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver from watchdog.events import PatternMatchingEventHandler from stashapi.stashapp import StashInterface import logging +import sys +from enum import Enum + +#the type of watcher being used; controls how to interpret the events +WatcherType = Enum('WatcherType', ['INOTIFY', 'WINDOWS', 'POLLING', 'KQUEUE']) #Setup logger logger = logging.getLogger("stash-watcher") @@ -24,6 +30,10 @@ modifiedFiles = {} + +currentWatcherType = None + + def log(msg): logger.info(msg) @@ -32,12 +42,57 @@ def debug(msg): def handleEvent(event): global shouldUpdate + global currentWatcherType debug("========EVENT========") debug(str(event)) #log(modifiedFiles) #Record if the file was modified. When a file is closed, see if it was modified. If so, trigger shouldTrigger = False - if event.is_directory == False: + + if event.is_directory == True: + return + #Depending on the watcher type, we have to handle these events differently + if currentWatcherType == WatcherType.WINDOWS: + #On windows here's what happens: + # File moved into a watched directory - Created Event + # File moved out of a watched directory - Deleted Event + # Moved within a watched directory (src and dst in watched directory) - Moved event + + # echo blah > foo.mp4 - Created then Modified + # copying a small file - Created then Modified + # copying a large file - Created then two (or more) Modified events (appears to be one when the file is created and another when it's finished) + + #It looks like you can get an optional Created Event and then + #either one or two Modified events. You can also get Moved events + + #For local files on Windows, they can't be opened if they're currently + #being written to. Therefore, every time we get an event, attempt to + #open the file. If we're successful, assume the write is finished and + #trigger the update. Otherwise wait until the next event and try again + if event.event_type == "created" or event.event_type == "modified": + try: + with open(event.src_path) as file: + debug("Successfully opened file; triggering") + shouldTrigger = True + except: + pass + + if event.event_type == "moved": + shouldTrigger = True + elif currentWatcherType == WatcherType.POLLING: + #Every interval you get 1 event per changed file + # - If the file was not present in the previous poll, then Created + # - If the file was present and has a new size, then Modified + # - If the file was moved within the directory, then Moved + # - If the file is gone, then deleted + # + # For now, just trigger on the created event. In the future, create + # a timer at 2x polling interval. Reschedule the timer on each event + # when it fires, trigger the update. + if event.event_type == "moved" or event.event_type == "created": + shouldTrigger = True + #Until someone tests this on mac, just do what INOTIFY does + elif currentWatcherType == WatcherType.INOTIFY or currentWatcherType == WatcherType.KQUEUE: if event.event_type == "modified": modifiedFiles[event.src_path] = 1 #These are for files being copied into the target @@ -50,6 +105,9 @@ def handleEvent(event): #moved out of a watched directory elif event.event_type == "moved": shouldTrigger = True + else: + print("Unknown watcher type " + str(currentWatcherType)) + sys.exit(1) #Trigger the update if shouldTrigger: @@ -59,8 +117,9 @@ def handleEvent(event): signal.notify() -def main(stash, scanFlags, paths, extensions, timeout): +def main(stash, scanFlags, paths, extensions, timeout, pollInterval): global shouldUpdate + global currentWatcherType if len(extensions) == 1 and extensions[0] == "*": patterns = ["*"] @@ -69,6 +128,21 @@ def main(stash, scanFlags, paths, extensions, timeout): eventHandler = PatternMatchingEventHandler(patterns, None, False, True) eventHandler.on_any_event = handleEvent observer = Observer() + observerName = type(observer).__name__ + if pollInterval != None and pollInterval > 0: + currentWatcherType = WatcherType.POLLING + observer = PollingObserver() + elif observerName == "WindowsApiObserver": + currentWatcherType = WatcherType.WINDOWS + elif observerName == "KqueueObserver": + currentWatcherType = WatcherType.KQUEUE + elif observerName == "InotifyObserver": + currentWatcherType = WatcherType.INOTIFY + else: + print("Unknown watcher type " + str(observer)) + sys.exit(1) + + debug(str(observer)) for path in paths: observer.schedule(eventHandler, path, recursive=True) observer.start() @@ -152,7 +226,18 @@ def parseConfig(path): stashConfig = stash.graphql_configuration() extensions = stashConfig['general']['videoExtensions'] + stashConfig['general']['imageExtensions'] + stashConfig['general']['galleryExtensions'] - main(stash, scanFlags, paths, extensions, timeout) + pollIntervalStr = config.get('Config', 'PollInterval') + if pollIntervalStr: + pollInterval = int(pollIntervalStr) + else: + pollInterval = None + + if config.get('Config', 'Debug') == "true": + logger.setLevel(logging.DEBUG) + ch.setLevel(logging.DEBUG) + + + main(stash, scanFlags, paths, extensions, timeout, pollInterval) From b89338038ad724b65712e2be91118877f2bd5c45 Mon Sep 17 00:00:00 2001 From: DuctTape42 <110079000+DuctTape42@users.noreply.github.com> Date: Tue, 3 Oct 2023 23:05:42 -0700 Subject: [PATCH 22/55] Update README.md Add notes to README about SMB shares --- scripts/stash-watcher/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/stash-watcher/README.md b/scripts/stash-watcher/README.md index 17b414b7..8dc518c6 100644 --- a/scripts/stash-watcher/README.md +++ b/scripts/stash-watcher/README.md @@ -8,6 +8,7 @@ Modify a [config.toml](config.toml) for your environment. The defaults match th * Paths * Timeout - the minimum time between Metadata Scans * Scan options - The options for the Metadata Scan +* Enable Polling - see [SMB/CIFS Shares](#smbcifs-shares) ## Running Stash Watcher You can run Stash Watcher directly from the [command line](#running-directly-with-python) or from inside [docker](#running-with-docker). @@ -53,3 +54,10 @@ Then you can run docker compose up -d --build ``` To start the watcher. + +## Notes +### SMB/CIFS shares +The library ([watchdog](https://pypi.org/project/watchdog/)) that Stash Watcher uses has some limitations when dealing with SMB/CIFS shares. If you encounter some problems, set [PollInterval in your config.toml](https://github.com/DuctTape42/CommunityScripts/blob/main/scripts/stash-watcher/defaults.toml#L28). This is a lot less efficient than the default mechanism, but is more likely to work. + +In my testing (this is from Windows to a share on another machine), if the machine running Stash Watcher wrote to the share, then the normal watcher worked fine. However, if a different machine wrote to the share, then Stash Watcher did not see the write unless I used Polling. + From a8e2d9bfb97e8b28a848ca99439e4417eb7b5ae1 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Wed, 18 Oct 2023 04:25:41 -0400 Subject: [PATCH 23/55] pdt-updates --- .../phashDuplicateTagger.py | 221 +++++++++++------- .../phashDuplicateTagger.yml | 20 +- 2 files changed, 141 insertions(+), 100 deletions(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index 9f9ee53d..e64226b7 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -5,7 +5,7 @@ try: import stashapi.log as log - from stashapi.tools import human_bytes + from stashapi.tools import human_bytes, human_bits from stashapi.stash_types import PhashDistance from stashapi.stashapp import StashInterface except ModuleNotFoundError: @@ -13,25 +13,29 @@ file=sys.stderr) -PRIORITY = ['resolution', 'bitrate', 'size', 'age'] # 'encoding' -CODEC_PRIORITY = ['H265','HEVC','H264','MPEG4'] +PRIORITY = ['resolution', 'bitrate', 'encoding', 'size', 'age'] +CODEC_PRIORITY = {'AV1':0,'H265':1,'HEVC':1,'H264':2,'MPEG4':3,'MPEG1VIDEO':3,'WMV3':4,'WMV2':5,'VC1':6,'SVQ3':7} FRAGMENT = json.loads(sys.stdin.read()) MODE = FRAGMENT['args']['mode'] stash = StashInterface(FRAGMENT["server_connection"]) SLIM_SCENE_FRAGMENT = """ - id - title - path - file_mod_time - tags { id } - file { - size - height - bitrate - video_codec - } +id +title +date +path +file_mod_time +tags { id } +file { + size + width + height + bitrate + duration + framerate + video_codec +} """ def main(): @@ -46,18 +50,18 @@ def main(): tag_id = stash.find_tag('[Dupe: Remove]').get("id") stash.destroy_tag(tag_id) - if MODE == "tagexact": - duplicate_list = stash.find_duplicate_scenes(PhashDistance.EXACT, fragment=SLIM_SCENE_FRAGMENT) - process_duplicates(duplicate_list) - if MODE == "taghigh": - duplicate_list = stash.find_duplicate_scenes(PhashDistance.HIGH, fragment=SLIM_SCENE_FRAGMENT) - process_duplicates(duplicate_list) - if MODE == "tagmid": - duplicate_list = stash.find_duplicate_scenes(PhashDistance.MEDIUM, fragment=SLIM_SCENE_FRAGMENT) - process_duplicates(duplicate_list) + if MODE == "tag_exact": + process_duplicates(PhashDistance.EXACT) + if MODE == "tag_high": + process_duplicates(PhashDistance.HIGH) + if MODE == "tag_medium": + process_duplicates(PhashDistance.MEDIUM) + + if MODE == "clean_scenes": + clean_scenes() + if MODE == "generate_phash": + generate_phash() - if MODE == "cleantitle": - clean_titles() log.exit("Plugin exited normally.") @@ -71,18 +75,31 @@ class StashScene: def __init__(self, scene=None) -> None: self.id = int(scene['id']) self.mod_time = parse_timestamp(scene['file_mod_time']) + if scene.get("date"): + self.date = parse_timestamp(scene['date'], format="%Y-%m-%d") + else: + self.date = None + self.path = scene.get("path") + self.width = scene['file']['width'] self.height = scene['file']['height'] + # File size in # of BYTES self.size = int(scene['file']['size']) + self.frame_rate = int(scene['file']['framerate']) self.bitrate = int(scene['file']['bitrate']) + self.duration = float(scene['file']['duration']) # replace any existing tagged title self.title = re.sub(r'^\[Dupe: \d+[KR]\]\s+', '', scene['title']) self.path = scene['path'] + self.tag_ids = [t["id"]for t in scene["tags"]] + + self.reason = None self.codec = scene['file']['video_codec'].upper() if self.codec in CODEC_PRIORITY: - self.codec = CODEC_PRIORITY.index(self.codec) + self.codec_priority = CODEC_PRIORITY[self.codec] else: - log.warning(f"could not find codec {self.codec}") + self.codec_priority = 99 + log.warning(f"could not find codec {self.codec} used in SceneID:{self.id}") def __repr__(self) -> str: return f'' @@ -115,65 +132,84 @@ def compare_resolution(self, other): # Checking Resolution if self.height != other.height: if self.height > other.height: - return self, f"Better Resolution {self.height} > {other.height} | {self.id}>{other.id}" + better, worse = self, other else: - return other, f"Better Resolution {other.height} > {self.height} | {other.id}>{self.id}" + worse, better = self, other + worse.reason = "resolution" + return better, f"Better Resolution {better.id}:{better.height}p > {worse.id}:{worse.height}p" return None, None def compare_bitrate(self, other): # Checking Bitrate if self.bitrate != other.bitrate: if self.bitrate > other.bitrate: - return self, f"Better Bitrate {human_bytes(self.bitrate)} > {human_bytes(other.bitrate)} Δ:({human_bytes(self.bitrate-other.bitrate)}) | {self.id}>{other.id}" + better, worse = self, other else: - return other, f"Better Bitrate {human_bytes(other.bitrate)} > {human_bytes(self.bitrate)} Δ:({human_bytes(other.bitrate-self.bitrate)}) | {other.id}>{self.id}" + worse, better = self, other + worse.reason = "bitrate" + return better, f"Better Bitrate {human_bits(better.bitrate)}ps > {human_bits(worse.bitrate)}ps Δ:({human_bits(better.bitrate-other.bitrate)}ps)" return None, None def compare_size(self, other): # Checking Size - if self.size != other.size: + if abs(self.size-other.size) > 100000: # diff is > than 0.1 Mb if self.size > other.size: - return self, f"Better Size {human_bytes(self.size)} > {human_bytes(other.size)} Δ:({human_bytes(self.size-other.size)}) | {self.id} > {other.id}" + better, worse = self, other else: - return other, f"Better Size {human_bytes(other.size)} > {human_bytes(self.size)} Δ:({human_bytes(other.size-self.size)}) | {other.id} > {self.id}" + worse, better = self, other + worse.reason = "file_size" + return better, f"Better Size {human_bytes(better.size)} > {human_bytes(worse.size)} Δ:({human_bytes(better.size-worse.size)})" return None, None def compare_age(self, other): # Checking Age - if self.mod_time != other.mod_time: - if self.mod_time < other.mod_time: - return self, f"Choose Oldest: Δ:{other.mod_time-self.mod_time} | {self.id} older than {other.id}" + if (self.date and other.date) and (self.date != other.date): + if self.date < other.date: + better, worse = self, other else: - return other, f"Choose Oldest: Δ:{self.mod_time-other.mod_time} | {other.id} older than {self.id}" + worse, better = self, other + worse.reason = "age" + return better, f"Choose Oldest: Δ:{worse.date-better.date} | {better.id} older than {worse.id}" return None, None def compare_encoding(self, other): - # could not find one of the codecs in priority list - if not isinstance(self.codec, int) or not isinstance(other.codec, int): - return None, None - if self.codec != other.codec: - if self.codec < other.codec: - return self, f"Preferred Codec {CODEC_PRIORITY[self.codec]} over {CODEC_PRIORITY[other.codec]} | {self.id} better than {other.id}" - else: - return other, f"Preferred Codec {CODEC_PRIORITY[other.codec]} over {CODEC_PRIORITY[self.codec]} | {other.id} better than {self.id}" + if self.codec_priority != other.codec_priority: + try: + if self.codec_priority < other.codec_priority: + better, worse = self, other + else: + worse, better = self, other + worse.reason = "video_codec" + return self, f"Prefer Codec {better.codec}({better.id}) over {worse.codec}({worse.id})" + except TypeError: + # could not find one of the codecs in priority list (comparing int to str) + pass return None, None -def process_duplicates(duplicate_list): +def process_duplicates(distance:PhashDistance=PhashDistance.EXACT): + + clean_scenes() # clean old results + ignore_tag_id = stash.find_tag('[Dupe: Ignore]', create=True).get("id") + duplicate_list = stash.find_duplicate_scenes(distance, fragment=SLIM_SCENE_FRAGMENT) + total = len(duplicate_list) log.info(f"There is {total} sets of duplicates found.") + for i, group in enumerate(duplicate_list): - log.progress(i/total) + group = [StashScene(s) for s in group] filtered_group = [] for scene in group: - tag_ids = [ t['id'] for t in scene['tags'] ] - if ignore_tag_id in tag_ids: - log.debug(f"Ignore {scene['id']} {scene['title']}") + if ignore_tag_id in scene.tag_ids: + log.debug(f"Ignore {scene.id} {scene.title}") else: filtered_group.append(scene) + if len(filtered_group) > 1: tag_files(filtered_group) + + log.progress(i/total) def tag_files(group): - tag_keep = stash.find_tag('[Dupe: Keep]', create=True).get("id") - tag_remove = stash.find_tag('[Dupe: Remove]', create=True).get("id") + keep_tag_id = stash.find_tag('[Dupe: Keep]', create=True).get("id") + remove_tag_id = stash.find_tag('[Dupe: Remove]', create=True).get("id") group = [StashScene(s) for s in group] @@ -185,6 +221,11 @@ def tag_files(group): keep_scene = better keep_reasons.append(msg) + if not keep_scene: + log.warning(f"could not determine better scene from {group}") + return + + total_size = human_bytes(total_size) keep_scene.reasons = keep_reasons log.info(f"{keep_scene.id} best of:{[s.id for s in group]} {keep_scene.reasons}") @@ -194,76 +235,76 @@ def tag_files(group): # log.debug(f"Tag for Keeping: {scene.id} {scene.path}") stash.update_scenes({ 'ids': [scene.id], - 'title': f'[Dupe: {keep_scene.id}K] {scene.title}', + 'title': f'[PDT: {total_size}|{keep_scene.id}K] {scene.title}', 'tag_ids': { 'mode': 'ADD', - 'ids': [tag_keep] + 'ids': [keep_tag_id] } }) else: - # log.debug(f"Tag for Removal: {scene.id} {scene.path}") + tag_ids = [remove_tag_id] + if scene.reason: + tag_ids.append(stash.find_tag(f'[Reason: {scene.reason}]', create=True).get('id')) stash.update_scenes({ 'ids': [scene.id], - 'title': f'[Dupe: {keep_scene.id}R] {scene.title}', + 'title': f'[PDT: {total_size}|{keep_scene.id}R] {scene.title}', 'tag_ids': { 'mode': 'ADD', - 'ids': [tag_remove] + 'ids': [remove_tag_id] } }) -def clean_titles(): +def clean_scenes(): scenes = stash.find_scenes(f={ "title": { "modifier": "MATCHES_REGEX", - "value": "^\\[Dupe: (\\d+)([KR])\\]" + "value": "^\\[PDT: .+?\\]" } },fragment="id title") log.info(f"Cleaning Titles/Tags of {len(scenes)} Scenes ") + # Clean scene Title for scene in scenes: - title = re.sub(r'\[Dupe: \d+[KR]\]\s+', '', scene['title']) + title = re.sub(r'\[PDT: .+?\]\s+', '', scene['title']) log.info(f"Removing Dupe Title String from: [{scene['id']}] {scene['title']}") stash.update_scenes({ 'ids': [scene['id']], 'title': title }) - tag_keep = stash.find_tag('[Dupe: Keep]') - if tag_keep: - tag_keep = tag_keep['id'] - scenes = stash.find_scenes(f={ - "tags":{ - "value": [tag_keep], - "modifier": "INCLUDES", - "depth": 0 - } - },fragment="id title") + # Remove Tags + tag_ids_to_remove = [] + keep_tag = stash.find_tag('[Dupe: Keep]') + if keep_tag: + tag_ids_to_remove.append(keep_tag["id"]) + remove_tag = stash.find_tag('[Dupe: Remove]') + if remove_tag: + tag_ids_to_remove.append(remove_tag["id"]) + reason_tags = stash.find_tags(f={ + "name": { + "value": "^\\[Reason", + "modifier": "MATCHES_REGEX" + }}) + tag_ids_to_remove.extend([t["id"] for t in reason_tags]) + + for tag_id in tag_ids_to_remove: + scene_filter={"tags":{"value": [tag_id],"modifier": "INCLUDES","depth": 0}} stash.update_scenes({ - 'ids': [s['id'] for s in scenes], + 'ids': [s["id"] for s in stash.find_scenes(f=scene_filter, fragment="id")], 'tag_ids': { 'mode': 'REMOVE', - 'ids': [tag_keep] + 'ids': [tag_id] } }) - tag_remove = stash.find_tag('[Dupe: Remove]') - if tag_remove: - tag_remove = tag_remove['id'] - scenes = stash.find_scenes(f={ - "tags":{ - "value": [tag_remove], - "modifier": "INCLUDES", - "depth": 0 - } - },fragment="id title") - stash.update_scenes({ - 'ids': [s['id'] for s in scenes], - 'tag_ids': { - 'mode': 'REMOVE', - 'ids': [tag_remove] - } - }) + +def generate_phash(): + query = """mutation MetadataGenerate($input: GenerateMetadataInput!) { + metadataGenerate(input: $input) + }""" + variables = {"phashes", True} + stash._callGraphQL(query, variables) if __name__ == '__main__': main() diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.yml b/plugins/phashDuplicateTagger/phashDuplicateTagger.yml index 9a789fcc..99b0a351 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.yml +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.yml @@ -27,27 +27,27 @@ exec: - "{pluginDir}/phashDuplicateTagger.py" interface: raw tasks: - - name: 'Create [Dupe] Tags' - description: 'Create [Dupe: Keep] and [Dupe: Remove] scene tags for filtering ' - defaultArgs: - mode: create - name: 'Set Dupe Tags (EXACT)' description: 'Assign duplicates tags to Exact Match (Dist 0) scenes' defaultArgs: - mode: tagexact + mode: tag_exact - name: 'Set Dupe Tags (HIGH)' description: 'Assign duplicates tags to High Match (Dist 3) scenes' defaultArgs: - mode: taghigh + mode: tag_high - name: 'Set Dupe Tags (MEDIUM)' description: 'Assign duplicates tags to Medium Match (Dist 6) scenes (BE CAREFUL WITH THIS LEVEL)' defaultArgs: - mode: tagmid + mode: tag_medium - name: 'Remove [Dupe] Tags' description: 'Remove duplicates scene tags from Stash database' defaultArgs: mode: remove - - name: 'Strip [Dupe] From Titles' - description: 'Clean prefixed Dupe string from scene titles' + - name: 'Scene Cleanup' + description: 'Removes titles from scenes and any generated tags excluding [Dupe: Ignore]' + defaultArgs: + mode: clean_scenes + - name: 'Generate PHASH' + description: 'Generate PHASHs for all scenes where they are missing' defaultArgs: - mode: cleantitle + mode: generate_phash From 87f8e54f1f35f4f2da8894fa7d848c1ea5655197 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Wed, 18 Oct 2023 04:26:30 -0400 Subject: [PATCH 24/55] bump --- plugins/phashDuplicateTagger/phashDuplicateTagger.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.yml b/plugins/phashDuplicateTagger/phashDuplicateTagger.yml index 99b0a351..04efdfe1 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.yml +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.yml @@ -20,7 +20,7 @@ name: "PHash Duplicate Tagger" description: Will tag scenes based on duplicate PHashes for easier/safer removal. -version: 0.1.1 +version: 0.1.3 url: https://github.com/Darklyter/CommunityScripts exec: - python From 76874ae25f12fa3e1ebafeae59c627a13da917bd Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Thu, 19 Oct 2023 07:57:06 -0400 Subject: [PATCH 25/55] reduce tags --- .../phashDuplicateTagger.py | 56 +++++++------------ 1 file changed, 19 insertions(+), 37 deletions(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index e64226b7..77e46863 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -20,6 +20,7 @@ MODE = FRAGMENT['args']['mode'] stash = StashInterface(FRAGMENT["server_connection"]) +IGNORE_TAG_NAME = "[Dupe: Ignore]" SLIM_SCENE_FRAGMENT = """ id title @@ -39,16 +40,11 @@ """ def main(): - if MODE == "create": - stash.find_tag('[Dupe: Keep]', create=True) - stash.find_tag('[Dupe: Remove]', create=True) - stash.find_tag('[Dupe: Ignore]', create=True) if MODE == "remove": - tag_id = stash.find_tag('[Dupe: Keep]').get("id") - stash.destroy_tag(tag_id) - tag_id = stash.find_tag('[Dupe: Remove]').get("id") - stash.destroy_tag(tag_id) + clean_scenes() + for tag in get_managed_tags(): + stash.destroy_tag(tag["id"]) if MODE == "tag_exact": process_duplicates(PhashDistance.EXACT) @@ -70,6 +66,7 @@ def parse_timestamp(ts, format="%Y-%m-%dT%H:%M:%S%z"): ts = re.sub(r'\.\d+', "", ts) #remove fractional seconds return dt.datetime.strptime(ts, format) + class StashScene: def __init__(self, scene=None) -> None: @@ -187,7 +184,7 @@ def process_duplicates(distance:PhashDistance=PhashDistance.EXACT): clean_scenes() # clean old results - ignore_tag_id = stash.find_tag('[Dupe: Ignore]', create=True).get("id") + ignore_tag_id = stash.find_tag(IGNORE_TAG_NAME, create=True).get("id") duplicate_list = stash.find_duplicate_scenes(distance, fragment=SLIM_SCENE_FRAGMENT) total = len(duplicate_list) @@ -208,9 +205,6 @@ def process_duplicates(distance:PhashDistance=PhashDistance.EXACT): log.progress(i/total) def tag_files(group): - keep_tag_id = stash.find_tag('[Dupe: Keep]', create=True).get("id") - remove_tag_id = stash.find_tag('[Dupe: Remove]', create=True).get("id") - group = [StashScene(s) for s in group] keep_reasons = [] @@ -235,14 +229,10 @@ def tag_files(group): # log.debug(f"Tag for Keeping: {scene.id} {scene.path}") stash.update_scenes({ 'ids': [scene.id], - 'title': f'[PDT: {total_size}|{keep_scene.id}K] {scene.title}', - 'tag_ids': { - 'mode': 'ADD', - 'ids': [keep_tag_id] - } + 'title': f'[PDT: {total_size}|{keep_scene.id}K] {scene.title}' }) else: - tag_ids = [remove_tag_id] + tag_ids = [] if scene.reason: tag_ids.append(stash.find_tag(f'[Reason: {scene.reason}]', create=True).get('id')) stash.update_scenes({ @@ -250,7 +240,7 @@ def tag_files(group): 'title': f'[PDT: {total_size}|{keep_scene.id}R] {scene.title}', 'tag_ids': { 'mode': 'ADD', - 'ids': [remove_tag_id] + 'ids': tag_ids } }) @@ -274,31 +264,23 @@ def clean_scenes(): }) # Remove Tags - tag_ids_to_remove = [] - keep_tag = stash.find_tag('[Dupe: Keep]') - if keep_tag: - tag_ids_to_remove.append(keep_tag["id"]) - remove_tag = stash.find_tag('[Dupe: Remove]') - if remove_tag: - tag_ids_to_remove.append(remove_tag["id"]) - reason_tags = stash.find_tags(f={ - "name": { - "value": "^\\[Reason", - "modifier": "MATCHES_REGEX" - }}) - tag_ids_to_remove.extend([t["id"] for t in reason_tags]) - - for tag_id in tag_ids_to_remove: - scene_filter={"tags":{"value": [tag_id],"modifier": "INCLUDES","depth": 0}} + for tag in get_managed_tags(): + scene_filter={"tags":{"value": [tag['id']],"modifier": "INCLUDES","depth": 0}} stash.update_scenes({ 'ids': [s["id"] for s in stash.find_scenes(f=scene_filter, fragment="id")], 'tag_ids': { 'mode': 'REMOVE', - 'ids': [tag_id] + 'ids': [tag['id']] } }) - +def get_managed_tags(fragment="id name"): + return stash.find_tags(f={ + "name": { + "value": "^\\[Reason", + "modifier": "MATCHES_REGEX" + }}, fragment=fragment) + def generate_phash(): query = """mutation MetadataGenerate($input: GenerateMetadataInput!) { metadataGenerate(input: $input) From 83cc37aa3c4b96fef0e6f78235167ac13bef4319 Mon Sep 17 00:00:00 2001 From: scruffynerf Date: Thu, 19 Oct 2023 15:07:39 -0400 Subject: [PATCH 26/55] Delete plugins/renamer directory Obsolete plugin, replaced by RenamerOnUpdate --- plugins/renamer/README.md | 74 ---- plugins/renamer/config.py | 88 ----- plugins/renamer/log.py | 52 --- plugins/renamer/renamerTask.py | 647 -------------------------------- plugins/renamer/renamerTask.yml | 29 -- 5 files changed, 890 deletions(-) delete mode 100644 plugins/renamer/README.md delete mode 100644 plugins/renamer/config.py delete mode 100644 plugins/renamer/log.py delete mode 100644 plugins/renamer/renamerTask.py delete mode 100644 plugins/renamer/renamerTask.yml diff --git a/plugins/renamer/README.md b/plugins/renamer/README.md deleted file mode 100644 index 053f7a85..00000000 --- a/plugins/renamer/README.md +++ /dev/null @@ -1,74 +0,0 @@ - -# SQLITE Renamer for Stash (Task) -Using metadata from your stash to rename your file. - -## Requirement -- Stash -- Python 3+ (Tested on Python v3.9.1 64bit, Win10) -- Request Module (https://pypi.org/project/requests/) -- Windows 10 ? (No idea if this work for all OS) - -## Installation - -- Download the whole folder 'renamer' (config.py, log.py, renamerTask.py/.yml) -- Place it in your **plugins** folder (where the `config.yml` is) -- Reload plugins (Settings > Plugins) -- renamerTask should appear. - -### :exclamation: Make sure to configure the plugin by editing `config.py` before running it :exclamation: - -## Usage - -- You have tasks (Settings > Task): - - **Dry-Run 🔍**: Don't edit any file, just show in log. It will create `renamer_scan.txt` that contains every edit. - - **[DRYRUN] Check 10 scenes**: Check 10 scenes (by newest updated). - - **[DRYRUN] Check all scenes**: Check all scenes. - - **Process :pencil2:**: Edit your files, **don't touch Stash while doing this task**. - - **Process scanned scene from Dry-Run task**: Read `renamer_scan.txt` instead of checking all scenes. - - **Process 10 scenes**: Check 10 scenes (by newest updated). - - **Process all scenes**: Check all scenes. - -## Configuration - -- Read/Edit `config.py` - - I recommend setting the **log_file** as it can be useful to revert. -- If you have the **renamerOnUpdate** plugin, you can copy the `config.py` from it. - -### Example - -> Note: The priority is Tag > Studio > Default - -The config will be: -```py -# Change filename for scenes from 'Vixen' or 'Slayed' studio. -studio_templates = { - "Slayed": "$date $performer - $title [$studio]", - "Vixen": "$performer - $title [$studio]" -} -# Change filename if the tag 'rename_tag' is present. -tag_templates = { - "rename_tag": "$year $title - $studio $resolution $video_codec", -} -# Change filename no matter what -use_default_template = True -default_template = "$date $title" -# Use space as a performer separator -performer_splitchar = " " -# If the scene has more than 3 performers, the $performer field will be ignored. -performer_limit = 3 -``` -The scene was just scanned, everything is default (Title = Filename). - -Current filename: `Slayed.21.09.02.Ariana.Marie.Emily.Willis.And.Eliza.Ibarra.XXX.1080p.mp4` - -|Stash Field | Value | Filename | Trigger template | -|--|:---:|--|--| -| - | *Default* |`Slayed.21.09.02.Ariana.Marie.Emily.Willis.And.Eliza.Ibarra.XXX.1080p.mp4` | default_template -| ~Title| **Driver**| `Driver.mp4` | default_template -| +Date| **2021-09-02**| `2021-09-02 Driver.mp4` | default_template -| +Performer | **Ariana Marie
Emily Willis
Eliza Ibarra**| `2021-09-02 Driver.mp4` | default_template -| +Studio | **Vixen**| `Ariana Marie Emily Willis Eliza Ibarra - Driver [Vixen].mp4` | studio_templates [Vixen] -| ~Studio | **Slayed**| `2021-09-02 Ariana Marie Emily Willis Eliza Ibarra - Driver [Slayed].mp4` | studio_templates [Slayed] -| +Performer | **Elsa Jean**| `2021-09-02 Driver [Slayed].mp4` | studio_templates [Slayed]
**Reach performer_limit**. -| +Tag | **rename_tag**| `2021 Driver - Slayed HD h264.mp4` | tag_templates [rename_tag] - diff --git a/plugins/renamer/config.py b/plugins/renamer/config.py deleted file mode 100644 index b2421d71..00000000 --- a/plugins/renamer/config.py +++ /dev/null @@ -1,88 +0,0 @@ -################################################################### -# -# ----------------------------------------------------------------- -# Available: $date $year $performer $title $height $resolution $studio $parent_studio $studio_family $video_codec $audio_codec -# -note: -# $studio_family: If parent studio exist use it, else use the studio name. -# $performer: If more than * performers, this field will be ignored. Limit to fix at Settings section below (default: 3) -# $resolution: SD/HD/UHD/VERTICAL (for phone) | $height: 720p 1080p 4k 8k -# ----------------------------------------------------------------- -# e.g.: -# $title == Her Fantasy Ball -# $date $title == 2016-12-29 Her Fantasy Ball -# $year $title $height == 2016 Her Fantasy Ball 1080p -# $date $performer - $title [$studio] == 2016-12-29 Eva Lovia - Her Fantasy Ball [Sneaky Sex] -# $parent_studio $date $performer - $title == Reality Kings 2016-12-29 Eva Lovia - Her Fantasy Ball -# -#################################################################### -# TEMPLATE # - -# Priority : Tags > Studios > Default - -# templates to use for given tags -# add or remove as needed -tag_templates = { - "!1. Western": "$date $performer - $title [$studio]", - "!1. JAV": "$title", - "!1. Anime": "$title $date [$studio]" -} - -# adjust the below if you want to use studio names instead of tags for the renaming templates -studio_templates = { - -} - -# change to True to use the default template if no specific tag/studio is found -use_default_template = False -# default template, adjust as needed -default_template = "$date $title" - -###################################### -# Logging # - -# File to save what is renamed, can be useful if you need to revert changes. -# Will look like: IDSCENE|OLD_PATH|NEW_PATH -# Leave Blank ("") or use None if you don't want to use a log file, or a working path like: C:\Users\USERNAME\.stash\plugins\Hooks\rename_log.txt -log_file = r"" - -###################################### -# Settings # - -# Character to use as a performer separator. -performer_splitchar = " " -# Maximum number of performer names in the filename. If there are more than that in a scene the filename will not include any performer names! -performer_limit = 3 -# ignore male performers. -performer_ignore_male = False - -# If $performer is before $title, prevent having duplicate text. -# e.g.: -# Template used: $year $performer - $title -# 2016 Dani Daniels - Dani Daniels in ***.mp4 --> 2016 Dani Daniels in ***.mp4 -prevent_title_performer = False - -# Only rename 'Organized' scenes. -only_organized = False -# Field to remove if the path is too long. First in list will be removed then second then ... if length is still too long. -order_field = ["$video_codec", "$audio_codec", "$resolution", "$height", "$studio_family", "$studio", "$parent_studio","$performer"] -# Alternate way to show diff. Not useful at all. -alt_diff_display = False - -###################################### -# Module Related # - -# ! OPTIONAL module settings. Not needed for basic operation ! - -# = psutil module (https://pypi.org/project/psutil/) = -# Gets a list of all processes instead of stopping after the first one. Enabling it slows down the plugin -process_getall = False -# If the file is used by a process, the plugin will kill it. IT CAN MAKE STASH CRASH TOO. -process_kill_attach = False -# ========================= - -# = Unidecode module (https://pypi.org/project/Unidecode/) = -# Check site mentioned for more details. -# TL;DR: Prevent having non common characters by replacing them. -# Warning: If you have non-latin characters (Cyrillic, Kanji, Arabic, ...), the result will be extremely different. -use_ascii = False -# ========================= diff --git a/plugins/renamer/log.py b/plugins/renamer/log.py deleted file mode 100644 index f3812522..00000000 --- a/plugins/renamer/log.py +++ /dev/null @@ -1,52 +0,0 @@ -import sys - - -# Log messages sent from a plugin instance are transmitted via stderr and are -# encoded with a prefix consisting of special character SOH, then the log -# level (one of t, d, i, w, e, or p - corresponding to trace, debug, info, -# warning, error and progress levels respectively), then special character -# STX. -# -# The LogTrace, LogDebug, LogInfo, LogWarning, and LogError methods, and their equivalent -# formatted methods are intended for use by plugin instances to transmit log -# messages. The LogProgress method is also intended for sending progress data. -# - -def __prefix(level_char): - start_level_char = b'\x01' - end_level_char = b'\x02' - - ret = start_level_char + level_char + end_level_char - return ret.decode() - - -def __log(level_char, s): - if level_char == "": - return - - print(__prefix(level_char) + s + "\n", file=sys.stderr, flush=True) - - -def LogTrace(s): - __log(b't', s) - - -def LogDebug(s): - __log(b'd', s) - - -def LogInfo(s): - __log(b'i', s) - - -def LogWarning(s): - __log(b'w', s) - - -def LogError(s): - __log(b'e', s) - - -def LogProgress(p): - progress = min(max(0, p), 1) - __log(b'p', str(progress)) diff --git a/plugins/renamer/renamerTask.py b/plugins/renamer/renamerTask.py deleted file mode 100644 index b4fb40bf..00000000 --- a/plugins/renamer/renamerTask.py +++ /dev/null @@ -1,647 +0,0 @@ -import difflib -import json -import os -import re -import sqlite3 -import subprocess -import sys -import time - -import requests - -try: - import psutil # pip install psutil - MODULE_PSUTIL = True -except: - MODULE_PSUTIL = False - -try: - import unidecode # pip install Unidecode - MODULE_UNIDECODE = True -except: - MODULE_UNIDECODE = False - -import config -import log - - -FRAGMENT = json.loads(sys.stdin.read()) -FRAGMENT_SERVER = FRAGMENT["server_connection"] -PLUGIN_DIR = FRAGMENT_SERVER["PluginDir"] -PLUGIN_ARGS = FRAGMENT['args'].get("mode") - -log.LogDebug("--Starting Plugin 'Renammer'--") - -#log.LogDebug("{}".format(FRAGMENT)) - -def callGraphQL(query, variables=None, raise_exception=True): - # Session cookie for authentication - graphql_port = FRAGMENT_SERVER['Port'] - graphql_scheme = FRAGMENT_SERVER['Scheme'] - graphql_cookies = { - 'session': FRAGMENT_SERVER.get('SessionCookie').get('Value') - } - graphql_headers = { - "Accept-Encoding": "gzip, deflate, br", - "Content-Type": "application/json", - "Accept": "application/json", - "Connection": "keep-alive", - "DNT": "1" - } - graphql_domain = 'localhost' - # Stash GraphQL endpoint - graphql_url = graphql_scheme + "://" + graphql_domain + ":" + str(graphql_port) + "/graphql" - - json = {'query': query} - if variables is not None: - json['variables'] = variables - try: - response = requests.post(graphql_url, json=json,headers=graphql_headers, cookies=graphql_cookies, timeout=20) - except Exception as e: - exit_plugin(err="[FATAL] Exception with GraphQL request. {}".format(e)) - if response.status_code == 200: - result = response.json() - if result.get("error"): - for error in result["error"]["errors"]: - if raise_exception: - raise Exception("GraphQL error: {}".format(error)) - else: - log.LogError("GraphQL error: {}".format(error)) - return None - if result.get("data"): - return result.get("data") - elif response.status_code == 401: - exit_plugin(err="HTTP Error 401, Unauthorised.") - else: - raise ConnectionError("GraphQL query failed: {} - {}".format(response.status_code, response.content)) - - -def graphql_getScene(scene_id): - query = """ - query FindScene($id: ID!, $checksum: String) { - findScene(id: $id, checksum: $checksum) { - ...SceneData - } - } - fragment SceneData on Scene { - id - checksum - oshash - title - details - url - date - rating - o_counter - organized - path - phash - interactive - file { - size - duration - video_codec - audio_codec - width - height - framerate - bitrate - } - studio { - ...SlimStudioData - } - movies { - movie { - ...MovieData - } - scene_index - } - tags { - ...SlimTagData - } - performers { - ...PerformerData - } - } - fragment SlimStudioData on Studio { - id - name - parent_studio { - id - name - } - details - rating - aliases - } - fragment MovieData on Movie { - id - checksum - name - aliases - date - rating - director - studio { - ...SlimStudioData - } - synopsis - url - } - fragment SlimTagData on Tag { - id - name - aliases - } - fragment PerformerData on Performer { - id - checksum - name - url - gender - twitter - instagram - birthdate - ethnicity - country - eye_color - height - measurements - fake_tits - career_length - tattoos - piercings - aliases - favorite - tags { - ...SlimTagData - } - rating - details - death_date - hair_color - weight - } - """ - variables = { - "id": scene_id - } - result = callGraphQL(query, variables) - return result.get('findScene') - - -def graphql_getConfiguration(): - query = """ - query Configuration { - configuration { - general { - databasePath - } - } - } - """ - result = callGraphQL(query) - return result.get('configuration') - - -def graphql_findScene(perPage,direc="DESC"): - query = """ - query FindScenes($filter: FindFilterType) { - findScenes(filter: $filter) { - count - scenes { - ...SlimSceneData - } - } - } - fragment SlimSceneData on Scene { - id - checksum - oshash - title - details - url - date - rating - o_counter - organized - path - phash - interactive - scene_markers { - id - title - seconds - } - galleries { - id - path - title - } - studio { - id - name - } - movies { - movie { - id - name - } - scene_index - } - tags { - id - name - } - performers { - id - name - gender - favorite - } - } - """ - # ASC DESC - variables = {'filter': {"direction": direc, "page": 1, "per_page": perPage, "sort": "updated_at"}} - result = callGraphQL(query, variables) - return result.get("findScenes") - - -def makeFilename(scene_information, query): - new_filename = str(query) - for field in TEMPLATE_FIELD: - field_name = field.replace("$","") - if field in new_filename: - if scene_information.get(field_name): - if field == "$performer": - if re.search(r"\$performer[-\s_]*\$title", new_filename) and scene_information.get('title') and PREVENT_TITLE_PERF: - if re.search("^{}".format(scene_information["performer"]), scene_information["title"]): - log.LogInfo("Ignoring the performer field because it's already in start of title") - new_filename = re.sub('\$performer[-\s_]*', '', new_filename) - continue - new_filename = new_filename.replace(field, scene_information[field_name]) - else: - new_filename = re.sub('\${}[-\s_]*'.format(field_name), '', new_filename) - - # remove [] - new_filename = re.sub('\[\W*]', '', new_filename) - # Remove multiple space/_ in row - new_filename = re.sub('[\s_]{2,}', ' ', new_filename) - # Remove multiple - in row - new_filename = re.sub('(?:[\s_]-){2,}', ' -', new_filename) - # Remove space at start/end - new_filename = new_filename.strip(" -") - return new_filename - - -def find_diff_text(a, b): - addi = minus = stay = "" - minus_ = addi_ = 0 - for _, s in enumerate(difflib.ndiff(a, b)): - if s[0] == ' ': - stay += s[-1] - minus += "*" - addi += "*" - elif s[0] == '-': - minus += s[-1] - minus_ += 1 - elif s[0] == '+': - addi += s[-1] - addi_ += 1 - if minus_ > 20 or addi_ > 20: - log.LogDebug("Diff Checker: +{}; -{};".format(addi_,minus_)) - log.LogDebug("OLD: {}".format(a)) - log.LogDebug("NEW: {}".format(b)) - else: - log.LogDebug("Original: {}\n- Charac: {}\n+ Charac: {}\n Result: {}".format(a, minus, addi, b)) - return - - -def has_handle(fpath,all_result=False): - lst = [] - for proc in psutil.process_iter(): - try: - for item in proc.open_files(): - if fpath == item.path: - if all_result: - lst.append(proc) - else: - return proc - except Exception: - pass - return lst - - -def exit_plugin(msg=None, err=None): - if msg is None and err is None: - msg = "plugin ended" - output_json = {"output": msg, "error": err} - print(json.dumps(output_json)) - sys.exit() - - -def renamer(scene_id): - filename_template = None - STASH_SCENE = graphql_getScene(scene_id) - # ================================================================ # - # RENAMER # - # Tags > Studios > Default - - # Default - if config.use_default_template: - filename_template = config.default_template - - # Change by Studio - if STASH_SCENE.get("studio") and config.studio_templates: - if config.studio_templates.get(STASH_SCENE["studio"]["name"]): - filename_template = config.studio_templates[STASH_SCENE["studio"]["name"]] - # by Parent - if STASH_SCENE["studio"].get("parent_studio"): - if config.studio_templates.get(STASH_SCENE["studio"]["name"]): - filename_template = config.studio_templates[STASH_SCENE["studio"]["name"]] - - # Change by Tag - if STASH_SCENE.get("tags") and config.tag_templates: - for tag in STASH_SCENE["tags"]: - if config.tag_templates.get(tag["name"]): - filename_template = config.tag_templates[tag["name"]] - break - - # END # - #################################################################### - - if config.only_organized and not STASH_SCENE["organized"]: - return("Scene ignored (not organized)") - - if not filename_template: - return("No template for this scene.") - - #log.LogDebug("Using this template: {}".format(filename_template)) - - current_path = STASH_SCENE["path"] - # note: contain the dot (.mp4) - file_extension = os.path.splitext(current_path)[1] - # note: basename contains the extension - current_filename = os.path.basename(current_path) - current_directory = os.path.dirname(current_path) - - # Grabbing things from Stash - scene_information = {} - - # Grab Title (without extension if present) - if STASH_SCENE.get("title"): - # Removing extension if present in title - scene_information["title"] = re.sub("{}$".format(file_extension), "", STASH_SCENE["title"]) - - # Grab Date - scene_information["date"] = STASH_SCENE.get("date") - - # Grab Performer - if STASH_SCENE.get("performers"): - perf_list = "" - perf_count = 0 - - for perf in STASH_SCENE["performers"]: - #log.LogDebug(performer) - if PERFORMER_IGNORE_MALE and perf["gender"] == "MALE": - continue - - if perf_count > PERFORMER_LIMIT: - # We've already exceeded the limit. No need to keep checking - break - - perf_list += perf["name"] + PERFORMER_SPLITCHAR - perf_count += 1 - - # Remove last character - perf_list = perf_list[:-len(PERFORMER_SPLITCHAR)] - - if perf_count > PERFORMER_LIMIT: - log.LogInfo("More than {} performer(s). Ignoring $performer".format(PERFORMER_LIMIT)) - perf_list = "" - - scene_information["performer"] = perf_list - - # Grab Studio name - if STASH_SCENE.get("studio"): - scene_information["studio"] = STASH_SCENE["studio"].get("name") - scene_information["studio_family"] = scene_information["studio"] - # Grab Parent name - if STASH_SCENE["studio"].get("parent_studio"): - scene_information["parent_studio"] = STASH_SCENE["studio"]["parent_studio"]["name"] - scene_information["studio_family"] = scene_information["parent_studio"] - - # Grab Height (720p,1080p,4k...) - scene_information["resolution"] = 'SD' - scene_information["height"] = "{}p".format(STASH_SCENE["file"]["height"]) - if STASH_SCENE["file"]["height"] >= 720: - scene_information["resolution"] = 'HD' - if STASH_SCENE["file"]["height"] >= 2160: - scene_information["height"] = '4k' - scene_information["resolution"] = 'UHD' - if STASH_SCENE["file"]["height"] >= 4320: - scene_information["height"] = '8k' - # For Phone ? - if STASH_SCENE["file"]["height"] > STASH_SCENE["file"]["width"]: - scene_information["resolution"] = 'VERTICAL' - - scene_information["video_codec"] = STASH_SCENE["file"]["video_codec"] - scene_information["audio_codec"] = STASH_SCENE["file"]["audio_codec"] - - log.LogDebug("[{}] Scene information: {}".format(scene_id,scene_information)) - - if scene_information.get("date"): - scene_information["year"] = scene_information["date"][0:4] - - - # Create the new filename - new_filename = makeFilename(scene_information, filename_template) + file_extension - - # Remove illegal character for Windows ('#' and ',' is not illegal you can remove it) - new_filename = re.sub('[\\/:"*?<>|#,]+', '', new_filename) - - # Trying to remove non standard character - if MODULE_UNIDECODE and UNICODE_USE: - new_filename = unidecode.unidecode(new_filename, errors='preserve') - else: - # Using typewriter for Apostrophe - new_filename = re.sub("[’‘]+", "'", new_filename) - - # Replace the old filename by the new in the filepath - new_path = current_path.rstrip(current_filename) + new_filename - - # Trying to prevent error with long path for Win10 - # https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd - if len(new_path) > 240: - log.LogWarning("The Path is too long ({})...".format(len(new_path))) - for word in ORDER_SHORTFIELD: - if word not in filename_template: - continue - filename_template = re.sub('\{}[-\s_]*'.format(word), '', filename_template).strip() - log.LogDebug("Removing field: {}".format(word)) - new_filename = makeFilename(scene_information, filename_template) + file_extension - new_path = current_path.rstrip(current_filename) + new_filename - if len(new_path) < 240: - log.LogInfo("Reduced filename to: {}".format(new_filename)) - break - if len(new_path) > 240: - return("Can't manage to reduce the path, operation aborted.") - - #log.LogDebug("Filename: {} -> {}".format(current_filename,new_filename)) - #log.LogDebug("Path: {} -> {}".format(current_path,new_path)) - - if (new_path == current_path): - return("Filename already correct. ({})".format(current_filename)) - - if ALT_DIFF_DISPLAY: - find_diff_text(current_filename,new_filename) - else: - log.LogDebug("[OLD] Filename: {}".format(current_filename)) - log.LogDebug("[NEW] Filename: {}".format(new_filename)) - if DRY_RUN: - with open(FILE_DRYRUN_RESULT, 'a', encoding='utf-8') as f: - f.write("{}|{}|{}\n".format(scene_id, current_filename, new_filename)) - return("[Dry-run] Writing in {}".format(FILE_DRYRUN_RESULT)) - - - # Connect to the DB - try: - sqliteConnection = sqlite3.connect(STASH_DATABASE) - cursor = sqliteConnection.cursor() - log.LogDebug("Python successfully connected to SQLite") - except sqlite3.Error as error: - return("FATAL SQLITE Error: {}".format(error)) - - # Looking for duplicate filename - folder_name = os.path.basename(os.path.dirname(new_path)) - cursor.execute("SELECT id FROM scenes WHERE path LIKE ? AND NOT id=?;", ["%" + folder_name + "_" + new_filename, scene_id]) - dupl_check = cursor.fetchall() - if len(dupl_check) > 0: - for dupl_row in dupl_check: - log.LogError("Same path: [{}]".format(dupl_row[0])) - return("Duplicate path detected, check log!") - - cursor.execute("SELECT id FROM scenes WHERE path LIKE ? AND NOT id=?;", ["%" + new_filename, scene_id]) - dupl_check = cursor.fetchall() - if len(dupl_check) > 0: - for dupl_row in dupl_check: - log.LogInfo("Same filename: [{}]".format(dupl_row[0])) - - # OS Rename - if (os.path.isfile(current_path) == True): - try: - os.rename(current_path, new_path) - except PermissionError as err: - if "[WinError 32]" in str(err) and MODULE_PSUTIL: - log.LogWarning("A process use this file, trying to find it (Probably FFMPEG)") - # Find what process access the file, it's ffmpeg for sure... - process_use = has_handle(current_path, PROCESS_ALLRESULT) - if process_use: - # Terminate the process then try again to rename - log.LogDebug("Process that use this file: {}".format(process_use)) - if PROCESS_KILL: - p = psutil.Process(process_use.pid) - p.terminate() - p.wait(10) - # If we don't manage to close it, this will create a error again. - os.rename(current_path, new_path) - else: - return("A process prevent editing the file.") - else: - log.LogError(err) - return "" - if (os.path.isfile(new_path) == True): - log.LogInfo("[OS] File Renamed!") - if LOGFILE: - with open(LOGFILE, 'a', encoding='utf-8') as f: - f.write("{}|{}|{}\n".format(scene_id, current_path, new_path)) - else: - # I don't think it's possible. - return("[OS] File failed to rename ? {}".format(new_path)) - else: - return("[OS] File don't exist in your Disk/Drive ({})".format(current_path)) - - # Database rename - cursor.execute("UPDATE scenes SET path=? WHERE id=?;", [new_path, scene_id]) - sqliteConnection.commit() - # Close DB - cursor.close() - sqliteConnection.close() - log.LogInfo("[SQLITE] Database updated and closed!") - return "" - - -# File that show what we will changed. -FILE_DRYRUN_RESULT = os.path.join(PLUGIN_DIR, "renamer_scan.txt") - -STASH_CONFIG = graphql_getConfiguration() -STASH_DATABASE = STASH_CONFIG["general"]["databasePath"] -TEMPLATE_FIELD = "$date $year $performer $title $height $resolution $studio $parent_studio $studio_family $video_codec $audio_codec".split(" ") - -# READING CONFIG - -LOGFILE = config.log_file -PERFORMER_SPLITCHAR = config.performer_splitchar -PERFORMER_LIMIT = config.performer_limit -PERFORMER_IGNORE_MALE = config.performer_ignore_male -PREVENT_TITLE_PERF = config.prevent_title_performer - -PROCESS_KILL = config.process_kill_attach -PROCESS_ALLRESULT = config.process_getall -UNICODE_USE = config.use_ascii - -ORDER_SHORTFIELD = config.order_field -ALT_DIFF_DISPLAY = config.alt_diff_display - -# Task -scenes = None -progress = 0 -start_time = time.time() - -if PLUGIN_ARGS in ["Process_test","Process_full","Process_dry"]: - DRY_RUN = False -else: - log.LogDebug("Dry-Run enable") - DRY_RUN = True - -if PLUGIN_ARGS in ["DRYRUN_test","Process_test"]: - scenes = graphql_findScene(10, "DESC") -if PLUGIN_ARGS in ["DRYRUN_full","Process_full"]: - scenes = graphql_findScene(-1, "ASC") -if PLUGIN_ARGS == "Process_dry": - if os.path.exists(FILE_DRYRUN_RESULT): - scenes = {"scenes":[]} - with open(FILE_DRYRUN_RESULT, 'r', encoding='utf-8') as f: - for line in f: - scene_id_file = line.split("|")[0] - scenes["scenes"].append({"id": scene_id_file}) - else: - exit_plugin(err="Can't find the file from the dry-run ({}). Be sure to run a Dry-Run task before.".format(FILE_DRYRUN_RESULT)) - -if not scenes: - exit_plugin(err="no scene") - -log.LogDebug("Count scenes: {}".format(len(scenes["scenes"]))) -progress_step = 1 / len(scenes["scenes"]) - -for scene in scenes["scenes"]: - msg = renamer(scene["id"]) - if msg: - log.LogDebug(msg) - progress += progress_step - log.LogProgress(progress) - -if PLUGIN_ARGS == "Process_dry": - os.remove(FILE_DRYRUN_RESULT) - -if DRY_RUN: - num_lines = 0 - if os.path.exists(FILE_DRYRUN_RESULT): - num_lines = sum(1 for _ in open(FILE_DRYRUN_RESULT, encoding='utf-8')) - if num_lines > 0: - log.LogInfo("[DRY-RUN] There wil be {} file(s) changed. Check {} for more details".format(num_lines, FILE_DRYRUN_RESULT)) - else: - log.LogInfo("[DRY-RUN] No change to do.") - -log.LogInfo("Took {} seconds".format(round(time.time() - start_time))) -exit_plugin("Successful!") diff --git a/plugins/renamer/renamerTask.yml b/plugins/renamer/renamerTask.yml deleted file mode 100644 index d629c505..00000000 --- a/plugins/renamer/renamerTask.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: renamerTask -description: Rename filename based to a template. -url: https://github.com/stashapp/CommunityScripts -version: 1.1 -exec: - - python - - "{pluginDir}/renamerTask.py" -interface: raw -tasks: - - name: '[DRYRUN] Check 10 scenes' - description: Only check 10 scenes. Just show in log and create a file with the possible change. - defaultArgs: - mode: DRYRUN_test - - name: '[DRYRUN] Check all scenes' - description: Check all scenes. Just show in log and create a file with the possible change. - defaultArgs: - mode: DRYRUN_full - - name: 'Process scanned scene from Dry-Run task' - description: Edit scenes listed on the textfile from the Dry-Run task. ! Don't do anything in Stash in same time ! - defaultArgs: - mode: Process_dry - - name: 'Process 10 scenes' - description: Edit the filename (if needed) for 10 scenes. ! Don't do anything in Stash in same time ! - defaultArgs: - mode: Process_test - - name: 'Process all scenes' - description: Edit the filename (if needed) for all scenes. ! Don't do anything in Stash in same time ! - defaultArgs: - mode: Process_full From be0e8e867f0785f27d3196074bfcb2275b4de336 Mon Sep 17 00:00:00 2001 From: DuctTape42 Date: Thu, 19 Oct 2023 16:15:52 -0700 Subject: [PATCH 27/55] Remove gitignore to ignore the gitignore --- scripts/stash-watcher/.gitignore | 160 ------------------------------- 1 file changed, 160 deletions(-) delete mode 100644 scripts/stash-watcher/.gitignore diff --git a/scripts/stash-watcher/.gitignore b/scripts/stash-watcher/.gitignore deleted file mode 100644 index 68bc17f9..00000000 --- a/scripts/stash-watcher/.gitignore +++ /dev/null @@ -1,160 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ From 85f4b3a249272039720d704519b6c71dff5f44e2 Mon Sep 17 00:00:00 2001 From: DuctTape42 Date: Thu, 19 Oct 2023 16:17:02 -0700 Subject: [PATCH 28/55] Remove blank lines --- scripts/stash-watcher/watcher.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/scripts/stash-watcher/watcher.py b/scripts/stash-watcher/watcher.py index eace46ca..623c7359 100644 --- a/scripts/stash-watcher/watcher.py +++ b/scripts/stash-watcher/watcher.py @@ -238,8 +238,3 @@ def parseConfig(path): main(stash, scanFlags, paths, extensions, timeout, pollInterval) - - - - - From e96c711a53cd3b7be8af2c74c34ab06fb38c1024 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Fri, 20 Oct 2023 10:33:45 -0400 Subject: [PATCH 29/55] update Set Scene Cover to use stashapp-tools issue 110 addressed within stashapp-tools flag --- plugins/setSceneCoverFromFile/log.py | 52 ------- plugins/setSceneCoverFromFile/set_cover.py | 40 +++-- .../setSceneCoverFromFile/set_scene_cover.yml | 6 +- .../setSceneCoverFromFile/stash_interface.py | 137 ------------------ 4 files changed, 27 insertions(+), 208 deletions(-) delete mode 100644 plugins/setSceneCoverFromFile/log.py delete mode 100644 plugins/setSceneCoverFromFile/stash_interface.py diff --git a/plugins/setSceneCoverFromFile/log.py b/plugins/setSceneCoverFromFile/log.py deleted file mode 100644 index e3d6aae2..00000000 --- a/plugins/setSceneCoverFromFile/log.py +++ /dev/null @@ -1,52 +0,0 @@ -import sys - - -# Log messages sent from a plugin instance are transmitted via stderr and are -# encoded with a prefix consisting of special character SOH, then the log -# level (one of t, d, i, w, e, or p - corresponding to trace, debug, info, -# warning, error and progress levels respectively), then special character -# STX. -# -# The LogTrace, LogDebug, LogInfo, LogWarning, and LogError methods, and their equivalent -# formatted methods are intended for use by plugin instances to transmit log -# messages. The LogProgress method is also intended for sending progress data. -# - -def __prefix(level_char): - start_level_char = b'\x01' - end_level_char = b'\x02' - - ret = start_level_char + level_char + end_level_char - return ret.decode() - - -def __log(level_char, s): - if level_char == "": - return - - print(__prefix(level_char) + s + "\n", file=sys.stderr, flush=True) - - -def trace(s): - __log(b't', s) - - -def debug(s): - __log(b'd', s) - - -def info(s): - __log(b'i', s) - - -def warning(s): - __log(b'w', s) - - -def error(s): - __log(b'e', s) - - -def progress(p): - progress = min(max(0, p), 1) - __log(b'p', str(progress)) diff --git a/plugins/setSceneCoverFromFile/set_cover.py b/plugins/setSceneCoverFromFile/set_cover.py index 321b55b5..ddb7be22 100644 --- a/plugins/setSceneCoverFromFile/set_cover.py +++ b/plugins/setSceneCoverFromFile/set_cover.py @@ -4,8 +4,13 @@ import json import base64 -import log -from stash_interface import StashInterface +try: + import stashapi.log as log + from stashapi.tools import file_to_base64 + from stashapi.stashapp import StashInterface +except ModuleNotFoundError: + print("You need to install the stashapi module. (pip install stashapp-tools)", + file=sys.stderr) MANUAL_ROOT = None # /some/other/path to override scanning all stashes cover_pattern = r'(?:thumb|poster|cover)\.(?:jpg|png)' @@ -21,7 +26,7 @@ def main(): if MANUAL_ROOT: scan(MANUAL_ROOT, handle_cover) else: - for stash_path in stash.get_root_paths(): + for stash_path in get_stash_paths(): scan(stash_path, handle_cover) except Exception as e: log.error(e) @@ -34,30 +39,32 @@ def handle_cover(path, file): filepath = os.path.join(path, file) - with open(filepath, "rb") as img: - b64img_bytes = base64.b64encode(img.read()) - - if not b64img_bytes: + b64img = file_to_base64(filepath) + if not b64img: + log.warning(f"Could not parse {filepath} to b64image") return - - b64img = f"data:image/jpeg;base64,{b64img_bytes.decode('utf-8')}" - scene_ids = stash.get_scenes_id(filter={ + scenes = stash.find_scenes(f={ "path": { "modifier": "INCLUDES", "value": f"{path}\"" } - }) + }, fragment="id") - log.info(f'Found Cover: {[int(s) for s in scene_ids]}|{filepath}') + log.info(f'Found Cover: {[int(s["id"]) for s in scenes]}|{filepath}') if mode_arg == "set_cover": - for scene_id in scene_ids: + for scene in scenes: stash.update_scene({ - "id": scene_id, + "id": scene["id"], "cover_image": b64img }) - log.info(f'Applied cover Scenes') + log.info(f'Applied cover to {len(scenes)} scenes') + +def get_stash_paths(): + config = stash.get_configuration("general { stashes { path excludeVideo } }") + stashes = config["configuration"]["general"]["stashes"] + return [s["path"] for s in stashes if not s["excludeVideo"]] def scan(ROOT_PATH, _callback): log.info(f'Scanning {ROOT_PATH}') @@ -66,4 +73,5 @@ def scan(ROOT_PATH, _callback): if re.match(cover_pattern, file, re.IGNORECASE): _callback(root, file) -main() \ No newline at end of file +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/plugins/setSceneCoverFromFile/set_scene_cover.yml b/plugins/setSceneCoverFromFile/set_scene_cover.yml index d11ae418..f622f35f 100644 --- a/plugins/setSceneCoverFromFile/set_scene_cover.yml +++ b/plugins/setSceneCoverFromFile/set_scene_cover.yml @@ -1,6 +1,6 @@ name: Set Scene Cover -description: Searchs Stash for Scenes with a cover image in the same folder and sets the cover image in stash to that image -version: 0.3 +description: searches Stash for Scenes with a cover image in the same folder and sets the cover image in stash to that image +version: 0.4 url: https://github.com/stg-annon/CommunityScripts/tree/main/plugins/setSceneCoverFromFile exec: - python @@ -8,7 +8,7 @@ exec: interface: raw tasks: - name: Scan - description: searchs stash dirs for cover images and logs results + description: searches stash dirs for cover images and logs results defaultArgs: mode: scan - name: Set Cover diff --git a/plugins/setSceneCoverFromFile/stash_interface.py b/plugins/setSceneCoverFromFile/stash_interface.py deleted file mode 100644 index 1223f409..00000000 --- a/plugins/setSceneCoverFromFile/stash_interface.py +++ /dev/null @@ -1,137 +0,0 @@ -import requests -import sys -import re -import log - - -class StashInterface: - port = "" - url = "" - headers = { - "Accept-Encoding": "gzip, deflate, br", - "Content-Type": "application/json", - "Accept": "application/json", - "Connection": "keep-alive", - "DNT": "1" - } - cookies = {} - - def __init__(self, conn, fragments={}): - self.port = conn['Port'] - scheme = conn['Scheme'] - - # Session cookie for authentication - self.cookies = { - 'session': conn.get('SessionCookie').get('Value') - } - - domain = conn.get('Domain') if conn.get('Domain') else 'localhost' - - # Stash GraphQL endpoint - self.url = scheme + "://" + domain + ":" + str(self.port) + "/graphql" - log.debug(f"Using stash GraphQl endpoint at {self.url}") - - self.fragments = fragments - self.fragments.update(stash_gql_fragments) - - def __resolveFragments(self, query): - - fragmentRefrences = list(set(re.findall(r'(?<=\.\.\.)\w+', query))) - fragments = [] - for ref in fragmentRefrences: - fragments.append({ - "fragment": ref, - "defined": bool(re.search("fragment {}".format(ref), query)) - }) - - if all([f["defined"] for f in fragments]): - return query - else: - for fragment in [f["fragment"] for f in fragments if not f["defined"]]: - if fragment not in self.fragments: - raise Exception(f'GraphQL error: fragment "{fragment}" not defined') - query += self.fragments[fragment] - return self.__resolveFragments(query) - - def __callGraphQL(self, query, variables=None): - - query = self.__resolveFragments(query) - - json = {'query': query} - if variables is not None: - json['variables'] = variables - - response = requests.post(self.url, json=json, headers=self.headers, cookies=self.cookies) - - if response.status_code == 200: - result = response.json() - if result.get("error", None): - for error in result["error"]["errors"]: - raise Exception("GraphQL error: {}".format(error)) - if result.get("data", None): - return result.get("data") - elif response.status_code == 401: - sys.exit("HTTP Error 401, Unauthorised. Cookie authentication most likely failed") - else: - raise ConnectionError( - "GraphQL query failed:{} - {}. Query: {}. Variables: {}".format( - response.status_code, response.content, query, variables) - ) - - - - def get_scenes_id(self, filter={}): - query = """ - query FindScenes($filter: FindFilterType, $scene_filter: SceneFilterType, $scene_ids: [Int!]) { - findScenes(filter: $filter, scene_filter: $scene_filter, scene_ids: $scene_ids) { - count - scenes { - id - } - } - } - """ - variables = { - "filter": { "per_page": -1 }, - "scene_filter": filter - } - - result = self.__callGraphQL(query, variables) - scene_ids = [s["id"] for s in result.get('findScenes').get('scenes')] - - return scene_ids - - def update_scene(self, scene_data): - query = """ - mutation SceneUpdate($input:SceneUpdateInput!) { - sceneUpdate(input: $input) { - id - } - } - """ - variables = {'input': scene_data} - - result = self.__callGraphQL(query, variables) - return result["sceneUpdate"]["id"] - - def get_root_paths(self): - query = """ - query Configuration { - configuration { - general{ - stashes{ - path - excludeVideo - } - } - } - } - """ - result = self.__callGraphQL(query) - - stashes = result["configuration"]["general"]["stashes"] - paths = [s["path"] for s in stashes if not s["excludeVideo"]] - - return paths - -stash_gql_fragments = {} \ No newline at end of file From b20e58025b465b027b50319d496522c32e09c75f Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Fri, 20 Oct 2023 11:01:14 -0400 Subject: [PATCH 30/55] prefer mod_time to date when comparing age --- plugins/phashDuplicateTagger/phashDuplicateTagger.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index 77e46863..e1074331 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -157,13 +157,13 @@ def compare_size(self, other): return None, None def compare_age(self, other): # Checking Age - if (self.date and other.date) and (self.date != other.date): - if self.date < other.date: + if (self.mod_time and other.mod_time) and (self.mod_time != other.mod_time): + if self.mod_time < other.mod_time: better, worse = self, other else: worse, better = self, other worse.reason = "age" - return better, f"Choose Oldest: Δ:{worse.date-better.date} | {better.id} older than {worse.id}" + return better, f"Choose Oldest: Δ:{worse.mod_time-better.mod_time} | {better.id} older than {worse.id}" return None, None def compare_encoding(self, other): if self.codec_priority != other.codec_priority: From 05b15a7cacf3f635851f0ee2280682ba025796b5 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Fri, 20 Oct 2023 13:47:14 -0400 Subject: [PATCH 31/55] fix deprecated fields --- .../phashDuplicateTagger.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index e1074331..c736b925 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -25,14 +25,13 @@ id title date -path -file_mod_time tags { id } -file { +files { size width height - bitrate + bit_rate + mod_time duration framerate video_codec @@ -70,28 +69,30 @@ def parse_timestamp(ts, format="%Y-%m-%dT%H:%M:%S%z"): class StashScene: def __init__(self, scene=None) -> None: + file = scene["files"][0] + self.id = int(scene['id']) - self.mod_time = parse_timestamp(scene['file_mod_time']) + self.mod_time = parse_timestamp(file['mod_time']) if scene.get("date"): self.date = parse_timestamp(scene['date'], format="%Y-%m-%d") else: self.date = None self.path = scene.get("path") - self.width = scene['file']['width'] - self.height = scene['file']['height'] + self.width = file['width'] + self.height = file['height'] # File size in # of BYTES - self.size = int(scene['file']['size']) - self.frame_rate = int(scene['file']['framerate']) - self.bitrate = int(scene['file']['bitrate']) - self.duration = float(scene['file']['duration']) + self.size = int(file['size']) + self.frame_rate = int(file['framerate']) + self.bitrate = int(file['bit_rate']) + self.duration = float(file['duration']) # replace any existing tagged title self.title = re.sub(r'^\[Dupe: \d+[KR]\]\s+', '', scene['title']) - self.path = scene['path'] + self.path = file['path'] self.tag_ids = [t["id"]for t in scene["tags"]] self.reason = None - self.codec = scene['file']['video_codec'].upper() + self.codec = file['video_codec'].upper() if self.codec in CODEC_PRIORITY: self.codec_priority = CODEC_PRIORITY[self.codec] else: From d4d4cfdb3e44eadda76a7fe61c7d6f8c80b74599 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Fri, 20 Oct 2023 13:54:23 -0400 Subject: [PATCH 32/55] re-introduce, keep/remove tags --- .../phashDuplicateTagger.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index c736b925..e8d6076e 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -20,7 +20,10 @@ MODE = FRAGMENT['args']['mode'] stash = StashInterface(FRAGMENT["server_connection"]) -IGNORE_TAG_NAME = "[Dupe: Ignore]" +KEEP_TAG_NAME = "[PDT: Keep]" +REMOVE_TAG_NAME = "[PDT: Remove]" +IGNORE_TAG_NAME = "[PDT: Ignore]" + SLIM_SCENE_FRAGMENT = """ id title @@ -227,13 +230,18 @@ def tag_files(group): for scene in group: if scene.id == keep_scene.id: - # log.debug(f"Tag for Keeping: {scene.id} {scene.path}") + tag_ids = [stash.find_tag(REMOVE_TAG_NAME, create=True).get("id")] stash.update_scenes({ 'ids': [scene.id], - 'title': f'[PDT: {total_size}|{keep_scene.id}K] {scene.title}' + 'title': f'[PDT: {total_size}|{keep_scene.id}K] {scene.title}', + 'tag_ids': { + 'mode': 'ADD', + 'ids': tag_ids + } }) else: tag_ids = [] + tag_ids.append(stash.find_tag(KEEP_TAG_NAME, create=True).get("id")) if scene.reason: tag_ids.append(stash.find_tag(f'[Reason: {scene.reason}]', create=True).get('id')) stash.update_scenes({ @@ -242,7 +250,7 @@ def tag_files(group): 'tag_ids': { 'mode': 'ADD', 'ids': tag_ids - } + } }) def clean_scenes(): @@ -276,11 +284,16 @@ def clean_scenes(): }) def get_managed_tags(fragment="id name"): - return stash.find_tags(f={ + tags = stash.find_tags(f={ "name": { "value": "^\\[Reason", "modifier": "MATCHES_REGEX" }}, fragment=fragment) + if remove_tag := stash.find_tag(REMOVE_TAG_NAME): + tags.append(remove_tag) + if keep_tag := stash.find_tag(KEEP_TAG_NAME): + tags.append(keep_tag) + return tags def generate_phash(): query = """mutation MetadataGenerate($input: GenerateMetadataInput!) { From 901f6cfce6008f3b0e4aca9ead7a3f3753a62e7e Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Fri, 20 Oct 2023 14:06:28 -0400 Subject: [PATCH 33/55] fix keep/remove tag --- plugins/phashDuplicateTagger/phashDuplicateTagger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index e8d6076e..767d9020 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -230,7 +230,7 @@ def tag_files(group): for scene in group: if scene.id == keep_scene.id: - tag_ids = [stash.find_tag(REMOVE_TAG_NAME, create=True).get("id")] + tag_ids = [stash.find_tag(KEEP_TAG_NAME, create=True).get("id")] stash.update_scenes({ 'ids': [scene.id], 'title': f'[PDT: {total_size}|{keep_scene.id}K] {scene.title}', @@ -241,7 +241,7 @@ def tag_files(group): }) else: tag_ids = [] - tag_ids.append(stash.find_tag(KEEP_TAG_NAME, create=True).get("id")) + tag_ids.append(stash.find_tag(REMOVE_TAG_NAME, create=True).get("id")) if scene.reason: tag_ids.append(stash.find_tag(f'[Reason: {scene.reason}]', create=True).get('id')) stash.update_scenes({ From 2ab4c9c00283a9f473990d62f4d1317f6fd002b5 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Fri, 20 Oct 2023 14:08:24 -0400 Subject: [PATCH 34/55] remove unnecessary comments --- plugins/phashDuplicateTagger/phashDuplicateTagger.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index 767d9020..8d58e457 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -112,9 +112,8 @@ def compare(self, other): if not (isinstance(other, StashScene)): raise Exception(f"can only compare to not <{type(other)}>") - # Check if same scene if self.id == other.id: - return None, "Matching IDs {self.id}=={other.id}" + return None, f"Matching IDs {self.id}=={other.id}" def compare_not_found(): raise Exception("comparison not found") @@ -130,7 +129,6 @@ def compare_not_found(): return None, f"{self.id} worse than {other.id}" def compare_resolution(self, other): - # Checking Resolution if self.height != other.height: if self.height > other.height: better, worse = self, other @@ -140,7 +138,6 @@ def compare_resolution(self, other): return better, f"Better Resolution {better.id}:{better.height}p > {worse.id}:{worse.height}p" return None, None def compare_bitrate(self, other): - # Checking Bitrate if self.bitrate != other.bitrate: if self.bitrate > other.bitrate: better, worse = self, other @@ -150,7 +147,6 @@ def compare_bitrate(self, other): return better, f"Better Bitrate {human_bits(better.bitrate)}ps > {human_bits(worse.bitrate)}ps Δ:({human_bits(better.bitrate-other.bitrate)}ps)" return None, None def compare_size(self, other): - # Checking Size if abs(self.size-other.size) > 100000: # diff is > than 0.1 Mb if self.size > other.size: better, worse = self, other @@ -160,7 +156,6 @@ def compare_size(self, other): return better, f"Better Size {human_bytes(better.size)} > {human_bytes(worse.size)} Δ:({human_bytes(better.size-worse.size)})" return None, None def compare_age(self, other): - # Checking Age if (self.mod_time and other.mod_time) and (self.mod_time != other.mod_time): if self.mod_time < other.mod_time: better, worse = self, other From bbe05e39b309df61aa81dc1507b3006376bfb554 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Fri, 20 Oct 2023 14:19:46 -0400 Subject: [PATCH 35/55] bugfixes --- .../phashDuplicateTagger.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index 8d58e457..2bab9fcd 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -31,12 +31,13 @@ tags { id } files { size + path width height bit_rate mod_time duration - framerate + frame_rate video_codec } """ @@ -85,7 +86,7 @@ def __init__(self, scene=None) -> None: self.height = file['height'] # File size in # of BYTES self.size = int(file['size']) - self.frame_rate = int(file['framerate']) + self.frame_rate = int(file['frame_rate']) self.bitrate = int(file['bit_rate']) self.duration = float(file['duration']) # replace any existing tagged title @@ -187,7 +188,7 @@ def process_duplicates(distance:PhashDistance=PhashDistance.EXACT): duplicate_list = stash.find_duplicate_scenes(distance, fragment=SLIM_SCENE_FRAGMENT) total = len(duplicate_list) - log.info(f"There is {total} sets of duplicates found.") + log.info(f"Found {total} sets of duplicates.") for i, group in enumerate(duplicate_list): group = [StashScene(s) for s in group] @@ -204,15 +205,17 @@ def process_duplicates(distance:PhashDistance=PhashDistance.EXACT): log.progress(i/total) def tag_files(group): - group = [StashScene(s) for s in group] - + keep_reasons = [] - keep_scene = group[0] + keep_scene = None + + total_size = group[0].size for scene in group[1:]: - better, msg = scene.compare(keep_scene) + total_size += scene.size + better, msg = scene.compare(group[0]) if better: keep_scene = better - keep_reasons.append(msg) + keep_reasons.append(msg) if not keep_scene: log.warning(f"could not determine better scene from {group}") From 1c4fedb7d185c8d12bce8327e0bd2155041f7863 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Sun, 22 Oct 2023 11:11:34 -0400 Subject: [PATCH 36/55] prefer Gigabytes format --- plugins/phashDuplicateTagger/phashDuplicateTagger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index 2bab9fcd..c98f8bf2 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -221,7 +221,7 @@ def tag_files(group): log.warning(f"could not determine better scene from {group}") return - total_size = human_bytes(total_size) + total_size = human_bytes(total_size, round=2, prefix='G') keep_scene.reasons = keep_reasons log.info(f"{keep_scene.id} best of:{[s.id for s in group]} {keep_scene.reasons}") From 73072a40f789914eac55d007356902d72e937f44 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Sun, 22 Oct 2023 13:18:30 -0400 Subject: [PATCH 37/55] move comments to readme --- plugins/phashDuplicateTagger/README.md | 66 ++++++++++++------- .../phashDuplicateTagger.yml | 34 ++-------- 2 files changed, 48 insertions(+), 52 deletions(-) diff --git a/plugins/phashDuplicateTagger/README.md b/plugins/phashDuplicateTagger/README.md index 377de4c4..1651eeb3 100644 --- a/plugins/phashDuplicateTagger/README.md +++ b/plugins/phashDuplicateTagger/README.md @@ -1,27 +1,43 @@ This plugin has four functions: -1) It will create two tags for review, [Dupe: Keep] and [Dupe: Remove] - -2) It will auto assign those tags to scenes with EXACT PHashes based on (and in this order): - a) Keep the larger resolution - b) Keep the larger file size (if same resolution) - c) Keep the older scene (if same file size.) - (Older scene is kept since it's more likely to have been organized if they're the same file) - With this order of precedence one scene is determined to be the "Keeper" and the rest are assigned for Removal - When the scenes are tagged, the titles are also modified to add '[Dupe: {SceneID}K/R]' - The SceneID put into the title is the one determined to be the "Keeper", and is put into all matching scenes - This way you can sort by title after matching and verify the scenes are actually the same thing, and the Keeper - will be the first scene in the set. (Since you'll have [Dupe: 72412K], [Dupe: 72412R], [Dupe: 72412R] as an example - - What I have personally done is essentially set a filter on the two Dupe tags, then sort by title. Then I spot check the - 'K' scenes versus the 'R' scenes. If everything looks good then I just drop [Dupe: Keep] out of the filter (leaving only - [Dupe: Remove], Select All and delete the files. - -3) It will remove the [Dupe: Keep] and [Dupe: Remove] tags from Stash -4) It will remove the [Dupe: ######K/R] tags from the titles - (These last two options are obviously for after you have removed the scenes you don't want any longer) - -PS. This script is essentially a hack and slash job on scripts from Belley and WithoutPants, thanks guys! - -PPS. The original plugin has been rewritten by stg-annon, and does now require hos stashapp-tools module (pip install stashapp-tools) - (Yes, this works with the Stash Docker) \ No newline at end of file +# PHASH Duplicate Tagger + +## Requirements + * python >= 3.10.X + * `pip install -r requirements.txt` + + +## Title Syntax + +This plugin will change the titles of scenes that are matched as duplicates in the following format + +`[PDT: 0.0GB|] ` + +group_id: usually the scene ID of the scene that was selected to Keep +keep_flag: K=Keep R=remove U=Unknown + + +## Tags +various tags may be created by this plugin +* Keep - Applied on scenes that are determined to be the "best" +* Remove - Applied to the scenes that determined to be the "worst" +* Unknown - Applied to scenes where a best scene could not be determined +* Ignore - Applied to scenes by user to ignore known duplicates +* Reason - These tags are applied to remove scenes, they will have a category that will match the determining factor on why a scene was chosen to be removed + +## Tasks +### Tag Dupes (EXACT/HIGH/MEDIUM) +These tasks will search for scenes with similar PHASHs within stash the closeness (distance) of the hashes to each other depends on which option you select + +* EXACT - Matches have a distance of 0 and should be exact matches +* HIGH - Matches have a distance of 3 and are very similar to each other +* MEDIUM - Matches have a distance of 6 and resemble each other + +### Delete Managed Tags +remove any generated tags within stash created by the plugin, excluding the `Ignore` tag this may be something you want to retain + +### Scene Cleanup +cleanup changes made to scene titles and tags back to before they were tagged + +### Generate Scene PHASHs +Start a generate task within stash to generate PHASHs \ No newline at end of file diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.yml b/plugins/phashDuplicateTagger/phashDuplicateTagger.yml index 04efdfe1..13f6629d 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.yml +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.yml @@ -1,53 +1,33 @@ -# This plugin has four functions: -# -# 1) It will create two tags for review, [Dupe: Keep] and [Dupe: Remove] -# 2) It will auto assign those tags to scenes with different degrees of matching PHashes based on (and in this order): -# a) Keep the larger resolution -# b) Keep the larger file size (if same resolution) -# c) Keep the older scene (if same file size.) -# (Older scene is kept since it's more likely to have been organized if they're the same file) -# With this order of precedence one scene is determined to be the "Keeper" and the rest are assigned for Removal -# When the scenes are tagged, the titles are also modified to add '[Dupe: {SceneID}K/R]' -# The SceneID put into the title is the one determined to be the "Keeper", and is put into all matching scenes -# This way you can sort by title after matching and verify the scenes are actually the same thing, and the Keeper -# will be the first scene in the set. (Since you'll have [Dupe: 72412K], [Dupe: 72412R], [Dupe: 72412R] as an example -# -# 3) It will remove the [Dupe: Keep] and [Dupe: Remove] tags from Stash -# 4) It will remove the [Dupe: ######K/R] tags from the titles -# (These last two options are obviously for after you have removed the scenes you don't want any longer) -# -# PS. This script is essentially a hack and slash job on scripts from Belley and WithoutPants, thanks guys! - name: "PHash Duplicate Tagger" description: Will tag scenes based on duplicate PHashes for easier/safer removal. version: 0.1.3 -url: https://github.com/Darklyter/CommunityScripts +url: https://github.com/stashapp/CommunityScripts/tree/main/plugins/phashDuplicateTagger exec: - python - "{pluginDir}/phashDuplicateTagger.py" interface: raw tasks: - - name: 'Set Dupe Tags (EXACT)' + - name: 'Tag Dupes (EXACT)' description: 'Assign duplicates tags to Exact Match (Dist 0) scenes' defaultArgs: mode: tag_exact - - name: 'Set Dupe Tags (HIGH)' + - name: 'Tag Dupes (HIGH)' description: 'Assign duplicates tags to High Match (Dist 3) scenes' defaultArgs: mode: tag_high - - name: 'Set Dupe Tags (MEDIUM)' + - name: 'Tag Dupes (MEDIUM)' description: 'Assign duplicates tags to Medium Match (Dist 6) scenes (BE CAREFUL WITH THIS LEVEL)' defaultArgs: mode: tag_medium - - name: 'Remove [Dupe] Tags' - description: 'Remove duplicates scene tags from Stash database' + - name: 'Delete Managed Tags' + description: 'Deletes tags managed by this plugin from stash' defaultArgs: mode: remove - name: 'Scene Cleanup' description: 'Removes titles from scenes and any generated tags excluding [Dupe: Ignore]' defaultArgs: mode: clean_scenes - - name: 'Generate PHASH' + - name: 'Generate Scene PHASHs' description: 'Generate PHASHs for all scenes where they are missing' defaultArgs: mode: generate_phash From 06d40a76038dd0751dcdc09e2a2771c252b2816d Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Sun, 22 Oct 2023 13:18:35 -0400 Subject: [PATCH 38/55] Update requirements.txt --- plugins/phashDuplicateTagger/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/phashDuplicateTagger/requirements.txt b/plugins/phashDuplicateTagger/requirements.txt index 03f728ba..cfcc6851 100644 --- a/plugins/phashDuplicateTagger/requirements.txt +++ b/plugins/phashDuplicateTagger/requirements.txt @@ -1 +1 @@ -stashapp-tools>=0.2.0 \ No newline at end of file +stashapp-tools>=0.2.33 \ No newline at end of file From e26134e43a83fa0d1e0ea7a4c7dc9fceda22cd87 Mon Sep 17 00:00:00 2001 From: stg-annon <14135675+stg-annon@users.noreply.github.com> Date: Sun, 22 Oct 2023 14:16:40 -0400 Subject: [PATCH 39/55] dynamically load compare funcs move configurable values and compare functions to config file --- plugins/phashDuplicateTagger/README.md | 8 +- plugins/phashDuplicateTagger/config.py | 110 +++++++++++++ .../phashDuplicateTagger.py | 148 +++++++----------- 3 files changed, 174 insertions(+), 92 deletions(-) create mode 100644 plugins/phashDuplicateTagger/config.py diff --git a/plugins/phashDuplicateTagger/README.md b/plugins/phashDuplicateTagger/README.md index 1651eeb3..4af7b3c2 100644 --- a/plugins/phashDuplicateTagger/README.md +++ b/plugins/phashDuplicateTagger/README.md @@ -40,4 +40,10 @@ remove any generated tags within stash created by the plugin, excluding the `Ign cleanup changes made to scene titles and tags back to before they were tagged ### Generate Scene PHASHs -Start a generate task within stash to generate PHASHs \ No newline at end of file +Start a generate task within stash to generate PHASHs + +## Custom Compare Functions + +you can create custom compare functions inside config.py all current compare functions are provided custom functions must return two values when a better file is determined, the better object and a message string, optionally you can set `remove_reason` on the worse file and it will be tagged with that reason + +custom functions must start with "compare_" otherwise they will not be detected, make sure to add your function name to the PRIORITY list \ No newline at end of file diff --git a/plugins/phashDuplicateTagger/config.py b/plugins/phashDuplicateTagger/config.py new file mode 100644 index 00000000..f8ca6a9a --- /dev/null +++ b/plugins/phashDuplicateTagger/config.py @@ -0,0 +1,110 @@ +import stashapi.log as log +from stashapi.tools import human_bytes, human_bits + +PRIORITY = ['bitrate_per_pixel','resolution', 'bitrate', 'encoding', 'size', 'age'] +CODEC_PRIORITY = {'AV1':0,'H265':1,'HEVC':1,'H264':2,'MPEG4':3,'MPEG1VIDEO':3,'WMV3':4,'WMV2':5,'VC1':6,'SVQ3':7} + +KEEP_TAG_NAME = "[PDT: Keep]" +REMOVE_TAG_NAME = "[PDT: Remove]" +UNKNOWN_TAG_NAME = "[PDT: Unknown]" +IGNORE_TAG_NAME = "[PDT: Ignore]" + + +def compare_bitrate_per_pixel(self, other): + + try: + self_bpp = self.bitrate / (self.width * self.height * self.frame_rate) + except ZeroDivisionError: + log.warning(f'scene {self.id} has 0 in file value ({self.width}x{self.height} {self.frame_rate}fps)') + return + try: + other_bpp = other.bitrate / (other.width * other.height * other.frame_rate) + except ZeroDivisionError: + log.warning(f'scene {other.id} has 0 in file value ({other.width}x{other.height} {other.frame_rate}fps)') + return + + bpp_diff = abs(self_bpp-other_bpp) + if bpp_diff <= 0.01: + return + + if self_bpp > other_bpp: + better_bpp, worse_bpp = self_bpp, other_bpp + better, worse = self, other + else: + worse_bpp, better_bpp = self_bpp, other_bpp + worse, better = self, other + worse.remove_reason = "bitrate_per_pxl" + message = f'bitrate/pxl {better_bpp:.3f}bpp > {worse_bpp:.3f}bpp Δ:{bpp_diff:.3f}' + return better, message + +def compare_frame_rate(self, other): + if not self.frame_rate: + log.warning(f'scene {self.id} has no value for frame_rate') + if not other.frame_rate: + log.warning(f'scene {other.id} has no value for frame_rate') + + if abs(self.frame_rate-other.frame_rate) < 5: + return + + if self.frame_rate > other.frame_rate: + better, worse = self, other + else: + worse, better = self, other + worse.remove_reason = "frame_rate" + return better, f'Better FPS {better.frame_rate} vs {worse.frame_rate}' + +def compare_resolution(self, other): + if self.height == other.height: + return + if self.height > other.height: + better, worse = self, other + else: + worse, better = self, other + worse.remove_reason = "resolution" + return better, f"Better Resolution {better.id}:{better.height}p > {worse.id}:{worse.height}p" + +def compare_bitrate(self, other): + if self.bitrate == other.bitrate: + return + if self.bitrate > other.bitrate: + better, worse = self, other + else: + worse, better = self, other + worse.remove_reason = "bitrate" + return better, f"Better Bitrate {human_bits(better.bitrate)}ps > {human_bits(worse.bitrate)}ps Δ:({human_bits(better.bitrate-other.bitrate)}ps)" + +def compare_size(self, other): + if abs(self.size-other.size) <= 100000: # diff is <= than 0.1 Mb + return + if self.size > other.size: + better, worse = self, other + else: + worse, better = self, other + worse.remove_reason = "file_size" + return better, f"Better Size {human_bytes(better.size)} > {human_bytes(worse.size)} Δ:({human_bytes(better.size-worse.size)})" + +def compare_age(self, other): + if not (self.mod_time and other.mod_time): + return + if self.mod_time == other.mod_time: + return + if self.mod_time < other.mod_time: + better, worse = self, other + else: + worse, better = self, other + worse.remove_reason = "age" + return better, f"Choose Oldest: Δ:{worse.mod_time-better.mod_time} | {better.id} older than {worse.id}" + +def compare_encoding(self, other): + if self.codec_priority == other.codec_priority: + return + if not (isinstance(self.codec_priority, int) and isinstance(other.codec_priority, int)): + return + + if self.codec_priority < other.codec_priority: + better, worse = self, other + else: + worse, better = self, other + worse.remove_reason = "video_codec" + return self, f"Prefer Codec {better.codec}({better.id}) over {worse.codec}({worse.id})" + \ No newline at end of file diff --git a/plugins/phashDuplicateTagger/phashDuplicateTagger.py b/plugins/phashDuplicateTagger/phashDuplicateTagger.py index c98f8bf2..494c44a9 100644 --- a/plugins/phashDuplicateTagger/phashDuplicateTagger.py +++ b/plugins/phashDuplicateTagger/phashDuplicateTagger.py @@ -1,7 +1,6 @@ -import json -import sys -import re +import re, sys, json import datetime as dt +from inspect import getmembers, isfunction try: import stashapi.log as log @@ -12,18 +11,12 @@ print("You need to install the stashapi module. (pip install stashapp-tools)", file=sys.stderr) - -PRIORITY = ['resolution', 'bitrate', 'encoding', 'size', 'age'] -CODEC_PRIORITY = {'AV1':0,'H265':1,'HEVC':1,'H264':2,'MPEG4':3,'MPEG1VIDEO':3,'WMV3':4,'WMV2':5,'VC1':6,'SVQ3':7} +import config FRAGMENT = json.loads(sys.stdin.read()) MODE = FRAGMENT['args']['mode'] stash = StashInterface(FRAGMENT["server_connection"]) -KEEP_TAG_NAME = "[PDT: Keep]" -REMOVE_TAG_NAME = "[PDT: Remove]" -IGNORE_TAG_NAME = "[PDT: Ignore]" - SLIM_SCENE_FRAGMENT = """ id title @@ -94,13 +87,13 @@ def __init__(self, scene=None) -> None: self.path = file['path'] self.tag_ids = [t["id"]for t in scene["tags"]] - self.reason = None + self.remove_reason = None self.codec = file['video_codec'].upper() - if self.codec in CODEC_PRIORITY: - self.codec_priority = CODEC_PRIORITY[self.codec] + if self.codec in config.CODEC_PRIORITY: + self.codec_priority = config.CODEC_PRIORITY[self.codec] else: - self.codec_priority = 99 + self.codec_priority = None log.warning(f"could not find codec {self.codec} used in SceneID:{self.id}") def __repr__(self) -> str: @@ -116,75 +109,25 @@ def compare(self, other): if self.id == other.id: return None, f"Matching IDs {self.id}=={other.id}" - def compare_not_found(): + def compare_not_found(*args, **kwargs): raise Exception("comparison not found") - for type in PRIORITY: + for type in config.PRIORITY: try: compare_function = getattr(self, f'compare_{type}', compare_not_found) - best, msg = compare_function(other) - if best: + result = compare_function(other) + if result and len(result) == 2: + best, msg = result return best, msg except Exception as e: - log.error(f"Issue Comparing <{type}> {e}") + log.error(f"Issue Comparing {self.id} {other.id} using <{type}> {e}") return None, f"{self.id} worse than {other.id}" - def compare_resolution(self, other): - if self.height != other.height: - if self.height > other.height: - better, worse = self, other - else: - worse, better = self, other - worse.reason = "resolution" - return better, f"Better Resolution {better.id}:{better.height}p > {worse.id}:{worse.height}p" - return None, None - def compare_bitrate(self, other): - if self.bitrate != other.bitrate: - if self.bitrate > other.bitrate: - better, worse = self, other - else: - worse, better = self, other - worse.reason = "bitrate" - return better, f"Better Bitrate {human_bits(better.bitrate)}ps > {human_bits(worse.bitrate)}ps Δ:({human_bits(better.bitrate-other.bitrate)}ps)" - return None, None - def compare_size(self, other): - if abs(self.size-other.size) > 100000: # diff is > than 0.1 Mb - if self.size > other.size: - better, worse = self, other - else: - worse, better = self, other - worse.reason = "file_size" - return better, f"Better Size {human_bytes(better.size)} > {human_bytes(worse.size)} Δ:({human_bytes(better.size-worse.size)})" - return None, None - def compare_age(self, other): - if (self.mod_time and other.mod_time) and (self.mod_time != other.mod_time): - if self.mod_time < other.mod_time: - better, worse = self, other - else: - worse, better = self, other - worse.reason = "age" - return better, f"Choose Oldest: Δ:{worse.mod_time-better.mod_time} | {better.id} older than {worse.id}" - return None, None - def compare_encoding(self, other): - if self.codec_priority != other.codec_priority: - try: - if self.codec_priority < other.codec_priority: - better, worse = self, other - else: - worse, better = self, other - worse.reason = "video_codec" - return self, f"Prefer Codec {better.codec}({better.id}) over {worse.codec}({worse.id})" - except TypeError: - # could not find one of the codecs in priority list (comparing int to str) - pass - return None, None - - def process_duplicates(distance:PhashDistance=PhashDistance.EXACT): clean_scenes() # clean old results - ignore_tag_id = stash.find_tag(IGNORE_TAG_NAME, create=True).get("id") + ignore_tag_id = stash.find_tag(config.IGNORE_TAG_NAME, create=True).get("id") duplicate_list = stash.find_duplicate_scenes(distance, fragment=SLIM_SCENE_FRAGMENT) total = len(duplicate_list) @@ -216,19 +159,29 @@ def tag_files(group): if better: keep_scene = better keep_reasons.append(msg) + total_size = human_bytes(total_size, round=2, prefix='G') if not keep_scene: - log.warning(f"could not determine better scene from {group}") + log.info(f"could not determine better scene from {group}") + if config.UNKNOWN_TAG_NAME: + group_id = group[0].id + for scene in group: + tag_ids = [stash.find_tag(config.UNKNOWN_TAG_NAME, create=True).get("id")] + stash.update_scenes({ + 'ids': [scene.id], + 'title': f'[PDT: {total_size}|{group_id}U] {scene.title}', + 'tag_ids': { + 'mode': 'ADD', + 'ids': tag_ids + } + }) return - total_size = human_bytes(total_size, round=2, prefix='G') - keep_scene.reasons = keep_reasons - - log.info(f"{keep_scene.id} best of:{[s.id for s in group]} {keep_scene.reasons}") + log.info(f"{keep_scene.id} best of:{[s.id for s in group]} {keep_reasons}") for scene in group: if scene.id == keep_scene.id: - tag_ids = [stash.find_tag(KEEP_TAG_NAME, create=True).get("id")] + tag_ids = [stash.find_tag(config.KEEP_TAG_NAME, create=True).get("id")] stash.update_scenes({ 'ids': [scene.id], 'title': f'[PDT: {total_size}|{keep_scene.id}K] {scene.title}', @@ -239,9 +192,9 @@ def tag_files(group): }) else: tag_ids = [] - tag_ids.append(stash.find_tag(REMOVE_TAG_NAME, create=True).get("id")) - if scene.reason: - tag_ids.append(stash.find_tag(f'[Reason: {scene.reason}]', create=True).get('id')) + tag_ids.append(stash.find_tag(config.REMOVE_TAG_NAME, create=True).get("id")) + if scene.remove_reason: + tag_ids.append(stash.find_tag(f'[Reason: {scene.remove_reason}]', create=True).get('id')) stash.update_scenes({ 'ids': [scene.id], 'title': f'[PDT: {total_size}|{keep_scene.id}R] {scene.title}', @@ -252,29 +205,34 @@ def tag_files(group): }) def clean_scenes(): - scenes = stash.find_scenes(f={ + scene_count, scenes = stash.find_scenes(f={ "title": { "modifier": "MATCHES_REGEX", "value": "^\\[PDT: .+?\\]" } - },fragment="id title") + },fragment="id title", get_count=True) - log.info(f"Cleaning Titles/Tags of {len(scenes)} Scenes ") + log.info(f"Cleaning Titles/Tags of {scene_count} Scenes ") # Clean scene Title - for scene in scenes: + for i, scene in enumerate(scenes): title = re.sub(r'\[PDT: .+?\]\s+', '', scene['title']) - log.info(f"Removing Dupe Title String from: [{scene['id']}] {scene['title']}") stash.update_scenes({ 'ids': [scene['id']], 'title': title }) + log.progress(i/scene_count) # Remove Tags for tag in get_managed_tags(): - scene_filter={"tags":{"value": [tag['id']],"modifier": "INCLUDES","depth": 0}} + scene_count, scenes = stash.find_scenes(f={ + "tags":{"value": [tag['id']],"modifier": "INCLUDES","depth": 0} + }, fragment="id", get_count=True) + if not scene_count > 0: + continue + log.info(f'removing tag {tag["name"]} from {scene_count} scenes') stash.update_scenes({ - 'ids': [s["id"] for s in stash.find_scenes(f=scene_filter, fragment="id")], + 'ids': [s["id"] for s in scenes], 'tag_ids': { 'mode': 'REMOVE', 'ids': [tag['id']] @@ -287,10 +245,15 @@ def get_managed_tags(fragment="id name"): "value": "^\\[Reason", "modifier": "MATCHES_REGEX" }}, fragment=fragment) - if remove_tag := stash.find_tag(REMOVE_TAG_NAME): - tags.append(remove_tag) - if keep_tag := stash.find_tag(KEEP_TAG_NAME): - tags.append(keep_tag) + tag_name_list = [ + config.REMOVE_TAG_NAME, + config.KEEP_TAG_NAME, + config.UNKNOWN_TAG_NAME, + # config.IGNORE_TAG_NAME, + ] + for tag_name in tag_name_list: + if tag := stash.find_tag(tag_name): + tags.append(tag) return tags def generate_phash(): @@ -301,4 +264,7 @@ def generate_phash(): stash._callGraphQL(query, variables) if __name__ == '__main__': + for name, func in getmembers(config, isfunction): + if re.match(r'^compare_', name): + setattr(StashScene, name, func) main() From 0158fd29fa2acf8e240849ca7bf0aa87744ea6e7 Mon Sep 17 00:00:00 2001 From: Scruffy Nerf Date: Sun, 22 Oct 2023 16:30:41 -0400 Subject: [PATCH 40/55] updating readme to add the start of v24 upgrade tracking --- README.md | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4c902a77..8fe92d6b 100644 --- a/README.md +++ b/README.md @@ -14,35 +14,40 @@ When downloading directly click on the file you want and then make sure to click # Plugin and Script Directory This list keeps track of scripts and plugins in this repository. Please ensure the list is kept in alphabetical order. +## NOTE: BREAKING CHANGES +The upcoming v24 release (and the current development branch) have breaking changes to schema, and also plugin changes. +We're beginning to review plugins and the rest and patch them to work, but it's an ongoing process. +We'll update the table below as we do this, but we STRONGLY recommend you do not use the development branch unless you are prepared to help with the patching. +We will also be rearranging things a bit, and updating documentation (including this page) + ## Plugins -Category|Triggers|Plugin Name|Description|Minimum Stash version +Category|Triggers|Plugin Name|Description|Minimum Stash version|Updated for v24 --------|-----------|-----------|-----------|--------------------- -Scraper|Task|[GHScraper_Checker](plugins/GHScraper_Checker)|Compare local file against github file from the community scraper repo.|v0.8 -Maintenance|Task
Scene.Update|[renamerOnUpdate](plugins/renamerOnUpdate)|Rename/Move your file based on Stash metadata.|v0.7 -Maintenance|Set Scene Cover|[setSceneCoverFromFile](plugins/setSceneCoverFromFile)|Searchs Stash for Scenes with a cover image in the same folder and sets the cover image in stash to that image|v0.7 -Scenes|SceneMarker.Create
SceneMarker.Update|[markerTagToScene](plugins/markerTagToScene)|Adds primary tag of Scene Marker to the Scene on marker create/update.|v0.8 ([46bbede](https://github.com/stashapp/stash/commit/46bbede9a07144797d6f26cf414205b390ca88f9)) -Scanning|Scene.Create
Gallery.Create
Image.Create|[defaultDataForPath](plugins/defaultDataForPath)|Adds configured Tags, Performers and/or Studio to all newly scanned Scenes, Images and Galleries..|v0.8 -Scanning|Scene.Create
Gallery.Create|[filenameParser](plugins/filenameParser)|Tries to parse filenames, primarily in {studio}.{year}.{month}.{day}.{performer1firstname}.{performer1lastname}.{performer2}.{title} format, into the respective fields|v0.10 -Scanning|Scene.Create|[pathParser](plugins/pathParser)|Updates scene info based on the file path.|v0.17 -Scanning|Scene.Create|[titleFromFilename](plugins/titleFromFilename)|Sets the scene title to its filename|v0.17 -Reporting||[TagGraph](plugins/tagGraph)|Creates a visual of the Tag relations.|v0.7 +Scraper|Task|[GHScraper_Checker](plugins/GHScraper_Checker)|Compare local file against github file from the community scraper repo.|v0.8|:x: +Maintenance|Task
Scene.Update|[renamerOnUpdate](plugins/renamerOnUpdate)|Rename/Move your file based on Stash metadata.|v0.7|:x: +Maintenance|Set Scene Cover|[setSceneCoverFromFile](plugins/setSceneCoverFromFile)|Searchs Stash for Scenes with a cover image in the same folder and sets the cover image in stash to that image|v0.7|:x: +Scenes|SceneMarker.Create
SceneMarker.Update|[markerTagToScene](plugins/markerTagToScene)|Adds primary tag of Scene Marker to the Scene on marker create/update.|v0.8 ([46bbede](https://github.com/stashapp/stash/commit/46bbede9a07144797d6f26cf414205b390ca88f9))|:x: +Scanning|Scene.Create
Gallery.Create
Image.Create|[defaultDataForPath](plugins/defaultDataForPath)|Adds configured Tags, Performers and/or Studio to all newly scanned Scenes, Images and Galleries..|v0.8|:x: +Scanning|Scene.Create
Gallery.Create|[filenameParser](plugins/filenameParser)|Tries to parse filenames, primarily in {studio}.{year}.{month}.{day}.{performer1firstname}.{performer1lastname}.{performer2}.{title} format, into the respective fields|v0.10|:x: +Scanning|Scene.Create|[pathParser](plugins/pathParser)|Updates scene info based on the file path.|v0.17|:x: +Scanning|Scene.Create|[titleFromFilename](plugins/titleFromFilename)|Sets the scene title to its filename|v0.17|:x: +Reporting||[TagGraph](plugins/tagGraph)|Creates a visual of the Tag relations.|v0.7|:x: ## Themes -Theme Name|Description | +Theme Name|Description |Updated for v24 ----------|--------------------------------------------| -[Plex](themes/plex) |Theme inspired by the popular Plex Interface| +[Plex](themes/plex) |Theme inspired by the popular Plex Interface|:x: ## Utility Scripts -|Category|Userscript Name|Description| +|Category|Userscript Name|Description|Updated for v24 ---------|---------------|-----------| -StashDB |[StashDB Submission Helper](/userscripts/StashDB_Submission_Helper)|Adds handy functions for StashDB submissions like buttons to add aliases in bulk to a performer| +StashDB |[StashDB Submission Helper](/userscripts/StashDB_Submission_Helper)|Adds handy functions for StashDB submissions like buttons to add aliases in bulk to a performer|:x: ## Utility Scripts -Category|Plugin Name|Description|Minimum Stash version +Category|Plugin Name|Description|Minimum Stash version|Updated for v24 --------|-----------|-----------|--------------------- -Kodi|[Kodi Helper](scripts/kodi-helper)|Generates `nfo` and `strm` for use with Kodi.|v0.7 -Maintenance|[Stash Sqlite Renamer](scripts/Sqlite_Renamer)|Renames your files using stash's metadata.|v0.7 +Kodi|[Kodi Helper](scripts/kodi-helper)|Generates `nfo` and `strm` for use with Kodi.|v0.7|:x: From d2d3f5eeda302d95d4a0f413d31e225174d87bde Mon Sep 17 00:00:00 2001 From: scruffynerf Date: Sun, 22 Oct 2023 16:36:00 -0400 Subject: [PATCH 41/55] Update README.md to actually have valid tables --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8fe92d6b..12cd7890 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ We will also be rearranging things a bit, and updating documentation (including ## Plugins -Category|Triggers|Plugin Name|Description|Minimum Stash version|Updated for v24 ---------|-----------|-----------|-----------|--------------------- +Category|Triggers|Plugin Name|Description|Minimum Stash version|Updated for v24| +--------|-----------|-----------|-----------|---------------------|----- Scraper|Task|[GHScraper_Checker](plugins/GHScraper_Checker)|Compare local file against github file from the community scraper repo.|v0.8|:x: Maintenance|Task
Scene.Update|[renamerOnUpdate](plugins/renamerOnUpdate)|Rename/Move your file based on Stash metadata.|v0.7|:x: Maintenance|Set Scene Cover|[setSceneCoverFromFile](plugins/setSceneCoverFromFile)|Searchs Stash for Scenes with a cover image in the same folder and sets the cover image in stash to that image|v0.7|:x: @@ -36,18 +36,18 @@ Reporting||[TagGraph](plugins/tagGraph)|Creates a visual of the Tag relations.|v ## Themes -Theme Name|Description |Updated for v24 -----------|--------------------------------------------| +Theme Name|Description |Updated for v24| +----------|--------------------------------------------|---- [Plex](themes/plex) |Theme inspired by the popular Plex Interface|:x: ## Utility Scripts -|Category|Userscript Name|Description|Updated for v24 ----------|---------------|-----------| +|Category|Userscript Name|Description|Updated for v24| +---------|---------------|-----------|---- StashDB |[StashDB Submission Helper](/userscripts/StashDB_Submission_Helper)|Adds handy functions for StashDB submissions like buttons to add aliases in bulk to a performer|:x: ## Utility Scripts -Category|Plugin Name|Description|Minimum Stash version|Updated for v24 ---------|-----------|-----------|--------------------- +Category|Plugin Name|Description|Minimum Stash version|Updated for v24| +--------|-----------|-----------|---------------------|---- Kodi|[Kodi Helper](scripts/kodi-helper)|Generates `nfo` and `strm` for use with Kodi.|v0.7|:x: From ce12a07e5100f0675063eb0ad5aa6ae5057eac18 Mon Sep 17 00:00:00 2001 From: feederbox826 Date: Tue, 24 Oct 2023 03:37:17 -0400 Subject: [PATCH 42/55] Add CropperJS and Scene Cropper as JS plugins --- plugins/4. CropperJS/CropperJS.yml | 11 + plugins/4. CropperJS/cropper.css | 308 ++ plugins/4. CropperJS/cropper.js | 3274 +++++++++++++++++ .../sceneCoverCropper/sceneCoverCropper.js | 145 + .../sceneCoverCropper/sceneCoverCropper.yml | 7 + 5 files changed, 3745 insertions(+) create mode 100644 plugins/4. CropperJS/CropperJS.yml create mode 100644 plugins/4. CropperJS/cropper.css create mode 100644 plugins/4. CropperJS/cropper.js create mode 100644 plugins/sceneCoverCropper/sceneCoverCropper.js create mode 100644 plugins/sceneCoverCropper/sceneCoverCropper.yml diff --git a/plugins/4. CropperJS/CropperJS.yml b/plugins/4. CropperJS/CropperJS.yml new file mode 100644 index 00000000..7b5b6cba --- /dev/null +++ b/plugins/4. CropperJS/CropperJS.yml @@ -0,0 +1,11 @@ +name: Cropper.JS +description: Exports cropper.js functionality for JS/Userscripts +version: 1.6.1 +ui: + css: + - cropper.css + javascript: + - cropper.js + +# note - not minimized for more transparency around updates & diffs against source code +# https://github.com/fengyuanchen/cropperjs/tree/main/dist \ No newline at end of file diff --git a/plugins/4. CropperJS/cropper.css b/plugins/4. CropperJS/cropper.css new file mode 100644 index 00000000..98a40ab8 --- /dev/null +++ b/plugins/4. CropperJS/cropper.css @@ -0,0 +1,308 @@ +/*! + * Cropper.js v1.6.1 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2023-09-17T03:44:17.565Z + */ + +.cropper-container { + direction: ltr; + font-size: 0; + line-height: 0; + position: relative; + -ms-touch-action: none; + touch-action: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.cropper-container img { + backface-visibility: hidden; + display: block; + height: 100%; + image-orientation: 0deg; + max-height: none !important; + max-width: none !important; + min-height: 0 !important; + min-width: 0 !important; + width: 100%; + } + +.cropper-wrap-box, +.cropper-canvas, +.cropper-drag-box, +.cropper-crop-box, +.cropper-modal { + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +.cropper-wrap-box, +.cropper-canvas { + overflow: hidden; +} + +.cropper-drag-box { + background-color: #fff; + opacity: 0; +} + +.cropper-modal { + background-color: #000; + opacity: 0.5; +} + +.cropper-view-box { + display: block; + height: 100%; + outline: 1px solid #39f; + outline-color: rgba(51, 153, 255, 0.75); + overflow: hidden; + width: 100%; +} + +.cropper-dashed { + border: 0 dashed #eee; + display: block; + opacity: 0.5; + position: absolute; +} + +.cropper-dashed.dashed-h { + border-bottom-width: 1px; + border-top-width: 1px; + height: calc(100% / 3); + left: 0; + top: calc(100% / 3); + width: 100%; + } + +.cropper-dashed.dashed-v { + border-left-width: 1px; + border-right-width: 1px; + height: 100%; + left: calc(100% / 3); + top: 0; + width: calc(100% / 3); + } + +.cropper-center { + display: block; + height: 0; + left: 50%; + opacity: 0.75; + position: absolute; + top: 50%; + width: 0; +} + +.cropper-center::before, + .cropper-center::after { + background-color: #eee; + content: ' '; + display: block; + position: absolute; + } + +.cropper-center::before { + height: 1px; + left: -3px; + top: 0; + width: 7px; + } + +.cropper-center::after { + height: 7px; + left: 0; + top: -3px; + width: 1px; + } + +.cropper-face, +.cropper-line, +.cropper-point { + display: block; + height: 100%; + opacity: 0.1; + position: absolute; + width: 100%; +} + +.cropper-face { + background-color: #fff; + left: 0; + top: 0; +} + +.cropper-line { + background-color: #39f; +} + +.cropper-line.line-e { + cursor: ew-resize; + right: -3px; + top: 0; + width: 5px; + } + +.cropper-line.line-n { + cursor: ns-resize; + height: 5px; + left: 0; + top: -3px; + } + +.cropper-line.line-w { + cursor: ew-resize; + left: -3px; + top: 0; + width: 5px; + } + +.cropper-line.line-s { + bottom: -3px; + cursor: ns-resize; + height: 5px; + left: 0; + } + +.cropper-point { + background-color: #39f; + height: 5px; + opacity: 0.75; + width: 5px; +} + +.cropper-point.point-e { + cursor: ew-resize; + margin-top: -3px; + right: -3px; + top: 50%; + } + +.cropper-point.point-n { + cursor: ns-resize; + left: 50%; + margin-left: -3px; + top: -3px; + } + +.cropper-point.point-w { + cursor: ew-resize; + left: -3px; + margin-top: -3px; + top: 50%; + } + +.cropper-point.point-s { + bottom: -3px; + cursor: s-resize; + left: 50%; + margin-left: -3px; + } + +.cropper-point.point-ne { + cursor: nesw-resize; + right: -3px; + top: -3px; + } + +.cropper-point.point-nw { + cursor: nwse-resize; + left: -3px; + top: -3px; + } + +.cropper-point.point-sw { + bottom: -3px; + cursor: nesw-resize; + left: -3px; + } + +.cropper-point.point-se { + bottom: -3px; + cursor: nwse-resize; + height: 20px; + opacity: 1; + right: -3px; + width: 20px; + } + +@media (min-width: 768px) { + +.cropper-point.point-se { + height: 15px; + width: 15px; + } + } + +@media (min-width: 992px) { + +.cropper-point.point-se { + height: 10px; + width: 10px; + } + } + +@media (min-width: 1200px) { + +.cropper-point.point-se { + height: 5px; + opacity: 0.75; + width: 5px; + } + } + +.cropper-point.point-se::before { + background-color: #39f; + bottom: -50%; + content: ' '; + display: block; + height: 200%; + opacity: 0; + position: absolute; + right: -50%; + width: 200%; + } + +.cropper-invisible { + opacity: 0; +} + +.cropper-bg { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC'); +} + +.cropper-hide { + display: block; + height: 0; + position: absolute; + width: 0; +} + +.cropper-hidden { + display: none !important; +} + +.cropper-move { + cursor: move; +} + +.cropper-crop { + cursor: crosshair; +} + +.cropper-disabled .cropper-drag-box, +.cropper-disabled .cropper-face, +.cropper-disabled .cropper-line, +.cropper-disabled .cropper-point { + cursor: not-allowed; +} diff --git a/plugins/4. CropperJS/cropper.js b/plugins/4. CropperJS/cropper.js new file mode 100644 index 00000000..55a50c88 --- /dev/null +++ b/plugins/4. CropperJS/cropper.js @@ -0,0 +1,3274 @@ +/*! + * Cropper.js v1.6.1 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2023-09-17T03:44:19.860Z + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Cropper = factory()); +})(this, (function () { 'use strict'; + + function ownKeys(e, r) { + var t = Object.keys(e); + if (Object.getOwnPropertySymbols) { + var o = Object.getOwnPropertySymbols(e); + r && (o = o.filter(function (r) { + return Object.getOwnPropertyDescriptor(e, r).enumerable; + })), t.push.apply(t, o); + } + return t; + } + function _objectSpread2(e) { + for (var r = 1; r < arguments.length; r++) { + var t = null != arguments[r] ? arguments[r] : {}; + r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { + _defineProperty(e, r, t[r]); + }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { + Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); + }); + } + return e; + } + function _typeof(o) { + "@babel/helpers - typeof"; + + return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { + return typeof o; + } : function (o) { + return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; + }, _typeof(o); + } + function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + } + function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); + } + } + function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) _defineProperties(Constructor.prototype, protoProps); + if (staticProps) _defineProperties(Constructor, staticProps); + Object.defineProperty(Constructor, "prototype", { + writable: false + }); + return Constructor; + } + function _defineProperty(obj, key, value) { + key = _toPropertyKey(key); + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + return obj; + } + function _toConsumableArray(arr) { + return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); + } + function _arrayWithoutHoles(arr) { + if (Array.isArray(arr)) return _arrayLikeToArray(arr); + } + function _iterableToArray(iter) { + if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); + } + function _unsupportedIterableToArray(o, minLen) { + if (!o) return; + if (typeof o === "string") return _arrayLikeToArray(o, minLen); + var n = Object.prototype.toString.call(o).slice(8, -1); + if (n === "Object" && o.constructor) n = o.constructor.name; + if (n === "Map" || n === "Set") return Array.from(o); + if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); + } + function _arrayLikeToArray(arr, len) { + if (len == null || len > arr.length) len = arr.length; + for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; + return arr2; + } + function _nonIterableSpread() { + throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); + } + function _toPrimitive(input, hint) { + if (typeof input !== "object" || input === null) return input; + var prim = input[Symbol.toPrimitive]; + if (prim !== undefined) { + var res = prim.call(input, hint || "default"); + if (typeof res !== "object") return res; + throw new TypeError("@@toPrimitive must return a primitive value."); + } + return (hint === "string" ? String : Number)(input); + } + function _toPropertyKey(arg) { + var key = _toPrimitive(arg, "string"); + return typeof key === "symbol" ? key : String(key); + } + + var IS_BROWSER = typeof window !== 'undefined' && typeof window.document !== 'undefined'; + var WINDOW = IS_BROWSER ? window : {}; + var IS_TOUCH_DEVICE = IS_BROWSER && WINDOW.document.documentElement ? 'ontouchstart' in WINDOW.document.documentElement : false; + var HAS_POINTER_EVENT = IS_BROWSER ? 'PointerEvent' in WINDOW : false; + var NAMESPACE = 'cropper'; + + // Actions + var ACTION_ALL = 'all'; + var ACTION_CROP = 'crop'; + var ACTION_MOVE = 'move'; + var ACTION_ZOOM = 'zoom'; + var ACTION_EAST = 'e'; + var ACTION_WEST = 'w'; + var ACTION_SOUTH = 's'; + var ACTION_NORTH = 'n'; + var ACTION_NORTH_EAST = 'ne'; + var ACTION_NORTH_WEST = 'nw'; + var ACTION_SOUTH_EAST = 'se'; + var ACTION_SOUTH_WEST = 'sw'; + + // Classes + var CLASS_CROP = "".concat(NAMESPACE, "-crop"); + var CLASS_DISABLED = "".concat(NAMESPACE, "-disabled"); + var CLASS_HIDDEN = "".concat(NAMESPACE, "-hidden"); + var CLASS_HIDE = "".concat(NAMESPACE, "-hide"); + var CLASS_INVISIBLE = "".concat(NAMESPACE, "-invisible"); + var CLASS_MODAL = "".concat(NAMESPACE, "-modal"); + var CLASS_MOVE = "".concat(NAMESPACE, "-move"); + + // Data keys + var DATA_ACTION = "".concat(NAMESPACE, "Action"); + var DATA_PREVIEW = "".concat(NAMESPACE, "Preview"); + + // Drag modes + var DRAG_MODE_CROP = 'crop'; + var DRAG_MODE_MOVE = 'move'; + var DRAG_MODE_NONE = 'none'; + + // Events + var EVENT_CROP = 'crop'; + var EVENT_CROP_END = 'cropend'; + var EVENT_CROP_MOVE = 'cropmove'; + var EVENT_CROP_START = 'cropstart'; + var EVENT_DBLCLICK = 'dblclick'; + var EVENT_TOUCH_START = IS_TOUCH_DEVICE ? 'touchstart' : 'mousedown'; + var EVENT_TOUCH_MOVE = IS_TOUCH_DEVICE ? 'touchmove' : 'mousemove'; + var EVENT_TOUCH_END = IS_TOUCH_DEVICE ? 'touchend touchcancel' : 'mouseup'; + var EVENT_POINTER_DOWN = HAS_POINTER_EVENT ? 'pointerdown' : EVENT_TOUCH_START; + var EVENT_POINTER_MOVE = HAS_POINTER_EVENT ? 'pointermove' : EVENT_TOUCH_MOVE; + var EVENT_POINTER_UP = HAS_POINTER_EVENT ? 'pointerup pointercancel' : EVENT_TOUCH_END; + var EVENT_READY = 'ready'; + var EVENT_RESIZE = 'resize'; + var EVENT_WHEEL = 'wheel'; + var EVENT_ZOOM = 'zoom'; + + // Mime types + var MIME_TYPE_JPEG = 'image/jpeg'; + + // RegExps + var REGEXP_ACTIONS = /^e|w|s|n|se|sw|ne|nw|all|crop|move|zoom$/; + var REGEXP_DATA_URL = /^data:/; + var REGEXP_DATA_URL_JPEG = /^data:image\/jpeg;base64,/; + var REGEXP_TAG_NAME = /^img|canvas$/i; + + // Misc + // Inspired by the default width and height of a canvas element. + var MIN_CONTAINER_WIDTH = 200; + var MIN_CONTAINER_HEIGHT = 100; + + var DEFAULTS = { + // Define the view mode of the cropper + viewMode: 0, + // 0, 1, 2, 3 + + // Define the dragging mode of the cropper + dragMode: DRAG_MODE_CROP, + // 'crop', 'move' or 'none' + + // Define the initial aspect ratio of the crop box + initialAspectRatio: NaN, + // Define the aspect ratio of the crop box + aspectRatio: NaN, + // An object with the previous cropping result data + data: null, + // A selector for adding extra containers to preview + preview: '', + // Re-render the cropper when resize the window + responsive: true, + // Restore the cropped area after resize the window + restore: true, + // Check if the current image is a cross-origin image + checkCrossOrigin: true, + // Check the current image's Exif Orientation information + checkOrientation: true, + // Show the black modal + modal: true, + // Show the dashed lines for guiding + guides: true, + // Show the center indicator for guiding + center: true, + // Show the white modal to highlight the crop box + highlight: true, + // Show the grid background + background: true, + // Enable to crop the image automatically when initialize + autoCrop: true, + // Define the percentage of automatic cropping area when initializes + autoCropArea: 0.8, + // Enable to move the image + movable: true, + // Enable to rotate the image + rotatable: true, + // Enable to scale the image + scalable: true, + // Enable to zoom the image + zoomable: true, + // Enable to zoom the image by dragging touch + zoomOnTouch: true, + // Enable to zoom the image by wheeling mouse + zoomOnWheel: true, + // Define zoom ratio when zoom the image by wheeling mouse + wheelZoomRatio: 0.1, + // Enable to move the crop box + cropBoxMovable: true, + // Enable to resize the crop box + cropBoxResizable: true, + // Toggle drag mode between "crop" and "move" when click twice on the cropper + toggleDragModeOnDblclick: true, + // Size limitation + minCanvasWidth: 0, + minCanvasHeight: 0, + minCropBoxWidth: 0, + minCropBoxHeight: 0, + minContainerWidth: MIN_CONTAINER_WIDTH, + minContainerHeight: MIN_CONTAINER_HEIGHT, + // Shortcuts of events + ready: null, + cropstart: null, + cropmove: null, + cropend: null, + crop: null, + zoom: null + }; + + var TEMPLATE = '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + '
'; + + /** + * Check if the given value is not a number. + */ + var isNaN = Number.isNaN || WINDOW.isNaN; + + /** + * Check if the given value is a number. + * @param {*} value - The value to check. + * @returns {boolean} Returns `true` if the given value is a number, else `false`. + */ + function isNumber(value) { + return typeof value === 'number' && !isNaN(value); + } + + /** + * Check if the given value is a positive number. + * @param {*} value - The value to check. + * @returns {boolean} Returns `true` if the given value is a positive number, else `false`. + */ + var isPositiveNumber = function isPositiveNumber(value) { + return value > 0 && value < Infinity; + }; + + /** + * Check if the given value is undefined. + * @param {*} value - The value to check. + * @returns {boolean} Returns `true` if the given value is undefined, else `false`. + */ + function isUndefined(value) { + return typeof value === 'undefined'; + } + + /** + * Check if the given value is an object. + * @param {*} value - The value to check. + * @returns {boolean} Returns `true` if the given value is an object, else `false`. + */ + function isObject(value) { + return _typeof(value) === 'object' && value !== null; + } + var hasOwnProperty = Object.prototype.hasOwnProperty; + + /** + * Check if the given value is a plain object. + * @param {*} value - The value to check. + * @returns {boolean} Returns `true` if the given value is a plain object, else `false`. + */ + function isPlainObject(value) { + if (!isObject(value)) { + return false; + } + try { + var _constructor = value.constructor; + var prototype = _constructor.prototype; + return _constructor && prototype && hasOwnProperty.call(prototype, 'isPrototypeOf'); + } catch (error) { + return false; + } + } + + /** + * Check if the given value is a function. + * @param {*} value - The value to check. + * @returns {boolean} Returns `true` if the given value is a function, else `false`. + */ + function isFunction(value) { + return typeof value === 'function'; + } + var slice = Array.prototype.slice; + + /** + * Convert array-like or iterable object to an array. + * @param {*} value - The value to convert. + * @returns {Array} Returns a new array. + */ + function toArray(value) { + return Array.from ? Array.from(value) : slice.call(value); + } + + /** + * Iterate the given data. + * @param {*} data - The data to iterate. + * @param {Function} callback - The process function for each element. + * @returns {*} The original data. + */ + function forEach(data, callback) { + if (data && isFunction(callback)) { + if (Array.isArray(data) || isNumber(data.length) /* array-like */) { + toArray(data).forEach(function (value, key) { + callback.call(data, value, key, data); + }); + } else if (isObject(data)) { + Object.keys(data).forEach(function (key) { + callback.call(data, data[key], key, data); + }); + } + } + return data; + } + + /** + * Extend the given object. + * @param {*} target - The target object to extend. + * @param {*} args - The rest objects for merging to the target object. + * @returns {Object} The extended object. + */ + var assign = Object.assign || function assign(target) { + for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + if (isObject(target) && args.length > 0) { + args.forEach(function (arg) { + if (isObject(arg)) { + Object.keys(arg).forEach(function (key) { + target[key] = arg[key]; + }); + } + }); + } + return target; + }; + var REGEXP_DECIMALS = /\.\d*(?:0|9){12}\d*$/; + + /** + * Normalize decimal number. + * Check out {@link https://0.30000000000000004.com/} + * @param {number} value - The value to normalize. + * @param {number} [times=100000000000] - The times for normalizing. + * @returns {number} Returns the normalized number. + */ + function normalizeDecimalNumber(value) { + var times = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 100000000000; + return REGEXP_DECIMALS.test(value) ? Math.round(value * times) / times : value; + } + var REGEXP_SUFFIX = /^width|height|left|top|marginLeft|marginTop$/; + + /** + * Apply styles to the given element. + * @param {Element} element - The target element. + * @param {Object} styles - The styles for applying. + */ + function setStyle(element, styles) { + var style = element.style; + forEach(styles, function (value, property) { + if (REGEXP_SUFFIX.test(property) && isNumber(value)) { + value = "".concat(value, "px"); + } + style[property] = value; + }); + } + + /** + * Check if the given element has a special class. + * @param {Element} element - The element to check. + * @param {string} value - The class to search. + * @returns {boolean} Returns `true` if the special class was found. + */ + function hasClass(element, value) { + return element.classList ? element.classList.contains(value) : element.className.indexOf(value) > -1; + } + + /** + * Add classes to the given element. + * @param {Element} element - The target element. + * @param {string} value - The classes to be added. + */ + function addClass(element, value) { + if (!value) { + return; + } + if (isNumber(element.length)) { + forEach(element, function (elem) { + addClass(elem, value); + }); + return; + } + if (element.classList) { + element.classList.add(value); + return; + } + var className = element.className.trim(); + if (!className) { + element.className = value; + } else if (className.indexOf(value) < 0) { + element.className = "".concat(className, " ").concat(value); + } + } + + /** + * Remove classes from the given element. + * @param {Element} element - The target element. + * @param {string} value - The classes to be removed. + */ + function removeClass(element, value) { + if (!value) { + return; + } + if (isNumber(element.length)) { + forEach(element, function (elem) { + removeClass(elem, value); + }); + return; + } + if (element.classList) { + element.classList.remove(value); + return; + } + if (element.className.indexOf(value) >= 0) { + element.className = element.className.replace(value, ''); + } + } + + /** + * Add or remove classes from the given element. + * @param {Element} element - The target element. + * @param {string} value - The classes to be toggled. + * @param {boolean} added - Add only. + */ + function toggleClass(element, value, added) { + if (!value) { + return; + } + if (isNumber(element.length)) { + forEach(element, function (elem) { + toggleClass(elem, value, added); + }); + return; + } + + // IE10-11 doesn't support the second parameter of `classList.toggle` + if (added) { + addClass(element, value); + } else { + removeClass(element, value); + } + } + var REGEXP_CAMEL_CASE = /([a-z\d])([A-Z])/g; + + /** + * Transform the given string from camelCase to kebab-case + * @param {string} value - The value to transform. + * @returns {string} The transformed value. + */ + function toParamCase(value) { + return value.replace(REGEXP_CAMEL_CASE, '$1-$2').toLowerCase(); + } + + /** + * Get data from the given element. + * @param {Element} element - The target element. + * @param {string} name - The data key to get. + * @returns {string} The data value. + */ + function getData(element, name) { + if (isObject(element[name])) { + return element[name]; + } + if (element.dataset) { + return element.dataset[name]; + } + return element.getAttribute("data-".concat(toParamCase(name))); + } + + /** + * Set data to the given element. + * @param {Element} element - The target element. + * @param {string} name - The data key to set. + * @param {string} data - The data value. + */ + function setData(element, name, data) { + if (isObject(data)) { + element[name] = data; + } else if (element.dataset) { + element.dataset[name] = data; + } else { + element.setAttribute("data-".concat(toParamCase(name)), data); + } + } + + /** + * Remove data from the given element. + * @param {Element} element - The target element. + * @param {string} name - The data key to remove. + */ + function removeData(element, name) { + if (isObject(element[name])) { + try { + delete element[name]; + } catch (error) { + element[name] = undefined; + } + } else if (element.dataset) { + // #128 Safari not allows to delete dataset property + try { + delete element.dataset[name]; + } catch (error) { + element.dataset[name] = undefined; + } + } else { + element.removeAttribute("data-".concat(toParamCase(name))); + } + } + var REGEXP_SPACES = /\s\s*/; + var onceSupported = function () { + var supported = false; + if (IS_BROWSER) { + var once = false; + var listener = function listener() {}; + var options = Object.defineProperty({}, 'once', { + get: function get() { + supported = true; + return once; + }, + /** + * This setter can fix a `TypeError` in strict mode + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Getter_only} + * @param {boolean} value - The value to set + */ + set: function set(value) { + once = value; + } + }); + WINDOW.addEventListener('test', listener, options); + WINDOW.removeEventListener('test', listener, options); + } + return supported; + }(); + + /** + * Remove event listener from the target element. + * @param {Element} element - The event target. + * @param {string} type - The event type(s). + * @param {Function} listener - The event listener. + * @param {Object} options - The event options. + */ + function removeListener(element, type, listener) { + var options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; + var handler = listener; + type.trim().split(REGEXP_SPACES).forEach(function (event) { + if (!onceSupported) { + var listeners = element.listeners; + if (listeners && listeners[event] && listeners[event][listener]) { + handler = listeners[event][listener]; + delete listeners[event][listener]; + if (Object.keys(listeners[event]).length === 0) { + delete listeners[event]; + } + if (Object.keys(listeners).length === 0) { + delete element.listeners; + } + } + } + element.removeEventListener(event, handler, options); + }); + } + + /** + * Add event listener to the target element. + * @param {Element} element - The event target. + * @param {string} type - The event type(s). + * @param {Function} listener - The event listener. + * @param {Object} options - The event options. + */ + function addListener(element, type, listener) { + var options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; + var _handler = listener; + type.trim().split(REGEXP_SPACES).forEach(function (event) { + if (options.once && !onceSupported) { + var _element$listeners = element.listeners, + listeners = _element$listeners === void 0 ? {} : _element$listeners; + _handler = function handler() { + delete listeners[event][listener]; + element.removeEventListener(event, _handler, options); + for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + listener.apply(element, args); + }; + if (!listeners[event]) { + listeners[event] = {}; + } + if (listeners[event][listener]) { + element.removeEventListener(event, listeners[event][listener], options); + } + listeners[event][listener] = _handler; + element.listeners = listeners; + } + element.addEventListener(event, _handler, options); + }); + } + + /** + * Dispatch event on the target element. + * @param {Element} element - The event target. + * @param {string} type - The event type(s). + * @param {Object} data - The additional event data. + * @returns {boolean} Indicate if the event is default prevented or not. + */ + function dispatchEvent(element, type, data) { + var event; + + // Event and CustomEvent on IE9-11 are global objects, not constructors + if (isFunction(Event) && isFunction(CustomEvent)) { + event = new CustomEvent(type, { + detail: data, + bubbles: true, + cancelable: true + }); + } else { + event = document.createEvent('CustomEvent'); + event.initCustomEvent(type, true, true, data); + } + return element.dispatchEvent(event); + } + + /** + * Get the offset base on the document. + * @param {Element} element - The target element. + * @returns {Object} The offset data. + */ + function getOffset(element) { + var box = element.getBoundingClientRect(); + return { + left: box.left + (window.pageXOffset - document.documentElement.clientLeft), + top: box.top + (window.pageYOffset - document.documentElement.clientTop) + }; + } + var location = WINDOW.location; + var REGEXP_ORIGINS = /^(\w+:)\/\/([^:/?#]*):?(\d*)/i; + + /** + * Check if the given URL is a cross origin URL. + * @param {string} url - The target URL. + * @returns {boolean} Returns `true` if the given URL is a cross origin URL, else `false`. + */ + function isCrossOriginURL(url) { + var parts = url.match(REGEXP_ORIGINS); + return parts !== null && (parts[1] !== location.protocol || parts[2] !== location.hostname || parts[3] !== location.port); + } + + /** + * Add timestamp to the given URL. + * @param {string} url - The target URL. + * @returns {string} The result URL. + */ + function addTimestamp(url) { + var timestamp = "timestamp=".concat(new Date().getTime()); + return url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp; + } + + /** + * Get transforms base on the given object. + * @param {Object} obj - The target object. + * @returns {string} A string contains transform values. + */ + function getTransforms(_ref) { + var rotate = _ref.rotate, + scaleX = _ref.scaleX, + scaleY = _ref.scaleY, + translateX = _ref.translateX, + translateY = _ref.translateY; + var values = []; + if (isNumber(translateX) && translateX !== 0) { + values.push("translateX(".concat(translateX, "px)")); + } + if (isNumber(translateY) && translateY !== 0) { + values.push("translateY(".concat(translateY, "px)")); + } + + // Rotate should come first before scale to match orientation transform + if (isNumber(rotate) && rotate !== 0) { + values.push("rotate(".concat(rotate, "deg)")); + } + if (isNumber(scaleX) && scaleX !== 1) { + values.push("scaleX(".concat(scaleX, ")")); + } + if (isNumber(scaleY) && scaleY !== 1) { + values.push("scaleY(".concat(scaleY, ")")); + } + var transform = values.length ? values.join(' ') : 'none'; + return { + WebkitTransform: transform, + msTransform: transform, + transform: transform + }; + } + + /** + * Get the max ratio of a group of pointers. + * @param {string} pointers - The target pointers. + * @returns {number} The result ratio. + */ + function getMaxZoomRatio(pointers) { + var pointers2 = _objectSpread2({}, pointers); + var maxRatio = 0; + forEach(pointers, function (pointer, pointerId) { + delete pointers2[pointerId]; + forEach(pointers2, function (pointer2) { + var x1 = Math.abs(pointer.startX - pointer2.startX); + var y1 = Math.abs(pointer.startY - pointer2.startY); + var x2 = Math.abs(pointer.endX - pointer2.endX); + var y2 = Math.abs(pointer.endY - pointer2.endY); + var z1 = Math.sqrt(x1 * x1 + y1 * y1); + var z2 = Math.sqrt(x2 * x2 + y2 * y2); + var ratio = (z2 - z1) / z1; + if (Math.abs(ratio) > Math.abs(maxRatio)) { + maxRatio = ratio; + } + }); + }); + return maxRatio; + } + + /** + * Get a pointer from an event object. + * @param {Object} event - The target event object. + * @param {boolean} endOnly - Indicates if only returns the end point coordinate or not. + * @returns {Object} The result pointer contains start and/or end point coordinates. + */ + function getPointer(_ref2, endOnly) { + var pageX = _ref2.pageX, + pageY = _ref2.pageY; + var end = { + endX: pageX, + endY: pageY + }; + return endOnly ? end : _objectSpread2({ + startX: pageX, + startY: pageY + }, end); + } + + /** + * Get the center point coordinate of a group of pointers. + * @param {Object} pointers - The target pointers. + * @returns {Object} The center point coordinate. + */ + function getPointersCenter(pointers) { + var pageX = 0; + var pageY = 0; + var count = 0; + forEach(pointers, function (_ref3) { + var startX = _ref3.startX, + startY = _ref3.startY; + pageX += startX; + pageY += startY; + count += 1; + }); + pageX /= count; + pageY /= count; + return { + pageX: pageX, + pageY: pageY + }; + } + + /** + * Get the max sizes in a rectangle under the given aspect ratio. + * @param {Object} data - The original sizes. + * @param {string} [type='contain'] - The adjust type. + * @returns {Object} The result sizes. + */ + function getAdjustedSizes(_ref4) { + var aspectRatio = _ref4.aspectRatio, + height = _ref4.height, + width = _ref4.width; + var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'contain'; + var isValidWidth = isPositiveNumber(width); + var isValidHeight = isPositiveNumber(height); + if (isValidWidth && isValidHeight) { + var adjustedWidth = height * aspectRatio; + if (type === 'contain' && adjustedWidth > width || type === 'cover' && adjustedWidth < width) { + height = width / aspectRatio; + } else { + width = height * aspectRatio; + } + } else if (isValidWidth) { + height = width / aspectRatio; + } else if (isValidHeight) { + width = height * aspectRatio; + } + return { + width: width, + height: height + }; + } + + /** + * Get the new sizes of a rectangle after rotated. + * @param {Object} data - The original sizes. + * @returns {Object} The result sizes. + */ + function getRotatedSizes(_ref5) { + var width = _ref5.width, + height = _ref5.height, + degree = _ref5.degree; + degree = Math.abs(degree) % 180; + if (degree === 90) { + return { + width: height, + height: width + }; + } + var arc = degree % 90 * Math.PI / 180; + var sinArc = Math.sin(arc); + var cosArc = Math.cos(arc); + var newWidth = width * cosArc + height * sinArc; + var newHeight = width * sinArc + height * cosArc; + return degree > 90 ? { + width: newHeight, + height: newWidth + } : { + width: newWidth, + height: newHeight + }; + } + + /** + * Get a canvas which drew the given image. + * @param {HTMLImageElement} image - The image for drawing. + * @param {Object} imageData - The image data. + * @param {Object} canvasData - The canvas data. + * @param {Object} options - The options. + * @returns {HTMLCanvasElement} The result canvas. + */ + function getSourceCanvas(image, _ref6, _ref7, _ref8) { + var imageAspectRatio = _ref6.aspectRatio, + imageNaturalWidth = _ref6.naturalWidth, + imageNaturalHeight = _ref6.naturalHeight, + _ref6$rotate = _ref6.rotate, + rotate = _ref6$rotate === void 0 ? 0 : _ref6$rotate, + _ref6$scaleX = _ref6.scaleX, + scaleX = _ref6$scaleX === void 0 ? 1 : _ref6$scaleX, + _ref6$scaleY = _ref6.scaleY, + scaleY = _ref6$scaleY === void 0 ? 1 : _ref6$scaleY; + var aspectRatio = _ref7.aspectRatio, + naturalWidth = _ref7.naturalWidth, + naturalHeight = _ref7.naturalHeight; + var _ref8$fillColor = _ref8.fillColor, + fillColor = _ref8$fillColor === void 0 ? 'transparent' : _ref8$fillColor, + _ref8$imageSmoothingE = _ref8.imageSmoothingEnabled, + imageSmoothingEnabled = _ref8$imageSmoothingE === void 0 ? true : _ref8$imageSmoothingE, + _ref8$imageSmoothingQ = _ref8.imageSmoothingQuality, + imageSmoothingQuality = _ref8$imageSmoothingQ === void 0 ? 'low' : _ref8$imageSmoothingQ, + _ref8$maxWidth = _ref8.maxWidth, + maxWidth = _ref8$maxWidth === void 0 ? Infinity : _ref8$maxWidth, + _ref8$maxHeight = _ref8.maxHeight, + maxHeight = _ref8$maxHeight === void 0 ? Infinity : _ref8$maxHeight, + _ref8$minWidth = _ref8.minWidth, + minWidth = _ref8$minWidth === void 0 ? 0 : _ref8$minWidth, + _ref8$minHeight = _ref8.minHeight, + minHeight = _ref8$minHeight === void 0 ? 0 : _ref8$minHeight; + var canvas = document.createElement('canvas'); + var context = canvas.getContext('2d'); + var maxSizes = getAdjustedSizes({ + aspectRatio: aspectRatio, + width: maxWidth, + height: maxHeight + }); + var minSizes = getAdjustedSizes({ + aspectRatio: aspectRatio, + width: minWidth, + height: minHeight + }, 'cover'); + var width = Math.min(maxSizes.width, Math.max(minSizes.width, naturalWidth)); + var height = Math.min(maxSizes.height, Math.max(minSizes.height, naturalHeight)); + + // Note: should always use image's natural sizes for drawing as + // imageData.naturalWidth === canvasData.naturalHeight when rotate % 180 === 90 + var destMaxSizes = getAdjustedSizes({ + aspectRatio: imageAspectRatio, + width: maxWidth, + height: maxHeight + }); + var destMinSizes = getAdjustedSizes({ + aspectRatio: imageAspectRatio, + width: minWidth, + height: minHeight + }, 'cover'); + var destWidth = Math.min(destMaxSizes.width, Math.max(destMinSizes.width, imageNaturalWidth)); + var destHeight = Math.min(destMaxSizes.height, Math.max(destMinSizes.height, imageNaturalHeight)); + var params = [-destWidth / 2, -destHeight / 2, destWidth, destHeight]; + canvas.width = normalizeDecimalNumber(width); + canvas.height = normalizeDecimalNumber(height); + context.fillStyle = fillColor; + context.fillRect(0, 0, width, height); + context.save(); + context.translate(width / 2, height / 2); + context.rotate(rotate * Math.PI / 180); + context.scale(scaleX, scaleY); + context.imageSmoothingEnabled = imageSmoothingEnabled; + context.imageSmoothingQuality = imageSmoothingQuality; + context.drawImage.apply(context, [image].concat(_toConsumableArray(params.map(function (param) { + return Math.floor(normalizeDecimalNumber(param)); + })))); + context.restore(); + return canvas; + } + var fromCharCode = String.fromCharCode; + + /** + * Get string from char code in data view. + * @param {DataView} dataView - The data view for read. + * @param {number} start - The start index. + * @param {number} length - The read length. + * @returns {string} The read result. + */ + function getStringFromCharCode(dataView, start, length) { + var str = ''; + length += start; + for (var i = start; i < length; i += 1) { + str += fromCharCode(dataView.getUint8(i)); + } + return str; + } + var REGEXP_DATA_URL_HEAD = /^data:.*,/; + + /** + * Transform Data URL to array buffer. + * @param {string} dataURL - The Data URL to transform. + * @returns {ArrayBuffer} The result array buffer. + */ + function dataURLToArrayBuffer(dataURL) { + var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, ''); + var binary = atob(base64); + var arrayBuffer = new ArrayBuffer(binary.length); + var uint8 = new Uint8Array(arrayBuffer); + forEach(uint8, function (value, i) { + uint8[i] = binary.charCodeAt(i); + }); + return arrayBuffer; + } + + /** + * Transform array buffer to Data URL. + * @param {ArrayBuffer} arrayBuffer - The array buffer to transform. + * @param {string} mimeType - The mime type of the Data URL. + * @returns {string} The result Data URL. + */ + function arrayBufferToDataURL(arrayBuffer, mimeType) { + var chunks = []; + + // Chunk Typed Array for better performance (#435) + var chunkSize = 8192; + var uint8 = new Uint8Array(arrayBuffer); + while (uint8.length > 0) { + // XXX: Babel's `toConsumableArray` helper will throw error in IE or Safari 9 + // eslint-disable-next-line prefer-spread + chunks.push(fromCharCode.apply(null, toArray(uint8.subarray(0, chunkSize)))); + uint8 = uint8.subarray(chunkSize); + } + return "data:".concat(mimeType, ";base64,").concat(btoa(chunks.join(''))); + } + + /** + * Get orientation value from given array buffer. + * @param {ArrayBuffer} arrayBuffer - The array buffer to read. + * @returns {number} The read orientation value. + */ + function resetAndGetOrientation(arrayBuffer) { + var dataView = new DataView(arrayBuffer); + var orientation; + + // Ignores range error when the image does not have correct Exif information + try { + var littleEndian; + var app1Start; + var ifdStart; + + // Only handle JPEG image (start by 0xFFD8) + if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) { + var length = dataView.byteLength; + var offset = 2; + while (offset + 1 < length) { + if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) { + app1Start = offset; + break; + } + offset += 1; + } + } + if (app1Start) { + var exifIDCode = app1Start + 4; + var tiffOffset = app1Start + 10; + if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') { + var endianness = dataView.getUint16(tiffOffset); + littleEndian = endianness === 0x4949; + if (littleEndian || endianness === 0x4D4D /* bigEndian */) { + if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) { + var firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian); + if (firstIFDOffset >= 0x00000008) { + ifdStart = tiffOffset + firstIFDOffset; + } + } + } + } + } + if (ifdStart) { + var _length = dataView.getUint16(ifdStart, littleEndian); + var _offset; + var i; + for (i = 0; i < _length; i += 1) { + _offset = ifdStart + i * 12 + 2; + if (dataView.getUint16(_offset, littleEndian) === 0x0112 /* Orientation */) { + // 8 is the offset of the current tag's value + _offset += 8; + + // Get the original orientation value + orientation = dataView.getUint16(_offset, littleEndian); + + // Override the orientation with its default value + dataView.setUint16(_offset, 1, littleEndian); + break; + } + } + } + } catch (error) { + orientation = 1; + } + return orientation; + } + + /** + * Parse Exif Orientation value. + * @param {number} orientation - The orientation to parse. + * @returns {Object} The parsed result. + */ + function parseOrientation(orientation) { + var rotate = 0; + var scaleX = 1; + var scaleY = 1; + switch (orientation) { + // Flip horizontal + case 2: + scaleX = -1; + break; + + // Rotate left 180° + case 3: + rotate = -180; + break; + + // Flip vertical + case 4: + scaleY = -1; + break; + + // Flip vertical and rotate right 90° + case 5: + rotate = 90; + scaleY = -1; + break; + + // Rotate right 90° + case 6: + rotate = 90; + break; + + // Flip horizontal and rotate right 90° + case 7: + rotate = 90; + scaleX = -1; + break; + + // Rotate left 90° + case 8: + rotate = -90; + break; + } + return { + rotate: rotate, + scaleX: scaleX, + scaleY: scaleY + }; + } + + var render = { + render: function render() { + this.initContainer(); + this.initCanvas(); + this.initCropBox(); + this.renderCanvas(); + if (this.cropped) { + this.renderCropBox(); + } + }, + initContainer: function initContainer() { + var element = this.element, + options = this.options, + container = this.container, + cropper = this.cropper; + var minWidth = Number(options.minContainerWidth); + var minHeight = Number(options.minContainerHeight); + addClass(cropper, CLASS_HIDDEN); + removeClass(element, CLASS_HIDDEN); + var containerData = { + width: Math.max(container.offsetWidth, minWidth >= 0 ? minWidth : MIN_CONTAINER_WIDTH), + height: Math.max(container.offsetHeight, minHeight >= 0 ? minHeight : MIN_CONTAINER_HEIGHT) + }; + this.containerData = containerData; + setStyle(cropper, { + width: containerData.width, + height: containerData.height + }); + addClass(element, CLASS_HIDDEN); + removeClass(cropper, CLASS_HIDDEN); + }, + // Canvas (image wrapper) + initCanvas: function initCanvas() { + var containerData = this.containerData, + imageData = this.imageData; + var viewMode = this.options.viewMode; + var rotated = Math.abs(imageData.rotate) % 180 === 90; + var naturalWidth = rotated ? imageData.naturalHeight : imageData.naturalWidth; + var naturalHeight = rotated ? imageData.naturalWidth : imageData.naturalHeight; + var aspectRatio = naturalWidth / naturalHeight; + var canvasWidth = containerData.width; + var canvasHeight = containerData.height; + if (containerData.height * aspectRatio > containerData.width) { + if (viewMode === 3) { + canvasWidth = containerData.height * aspectRatio; + } else { + canvasHeight = containerData.width / aspectRatio; + } + } else if (viewMode === 3) { + canvasHeight = containerData.width / aspectRatio; + } else { + canvasWidth = containerData.height * aspectRatio; + } + var canvasData = { + aspectRatio: aspectRatio, + naturalWidth: naturalWidth, + naturalHeight: naturalHeight, + width: canvasWidth, + height: canvasHeight + }; + this.canvasData = canvasData; + this.limited = viewMode === 1 || viewMode === 2; + this.limitCanvas(true, true); + canvasData.width = Math.min(Math.max(canvasData.width, canvasData.minWidth), canvasData.maxWidth); + canvasData.height = Math.min(Math.max(canvasData.height, canvasData.minHeight), canvasData.maxHeight); + canvasData.left = (containerData.width - canvasData.width) / 2; + canvasData.top = (containerData.height - canvasData.height) / 2; + canvasData.oldLeft = canvasData.left; + canvasData.oldTop = canvasData.top; + this.initialCanvasData = assign({}, canvasData); + }, + limitCanvas: function limitCanvas(sizeLimited, positionLimited) { + var options = this.options, + containerData = this.containerData, + canvasData = this.canvasData, + cropBoxData = this.cropBoxData; + var viewMode = options.viewMode; + var aspectRatio = canvasData.aspectRatio; + var cropped = this.cropped && cropBoxData; + if (sizeLimited) { + var minCanvasWidth = Number(options.minCanvasWidth) || 0; + var minCanvasHeight = Number(options.minCanvasHeight) || 0; + if (viewMode > 1) { + minCanvasWidth = Math.max(minCanvasWidth, containerData.width); + minCanvasHeight = Math.max(minCanvasHeight, containerData.height); + if (viewMode === 3) { + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } else { + minCanvasHeight = minCanvasWidth / aspectRatio; + } + } + } else if (viewMode > 0) { + if (minCanvasWidth) { + minCanvasWidth = Math.max(minCanvasWidth, cropped ? cropBoxData.width : 0); + } else if (minCanvasHeight) { + minCanvasHeight = Math.max(minCanvasHeight, cropped ? cropBoxData.height : 0); + } else if (cropped) { + minCanvasWidth = cropBoxData.width; + minCanvasHeight = cropBoxData.height; + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } else { + minCanvasHeight = minCanvasWidth / aspectRatio; + } + } + } + var _getAdjustedSizes = getAdjustedSizes({ + aspectRatio: aspectRatio, + width: minCanvasWidth, + height: minCanvasHeight + }); + minCanvasWidth = _getAdjustedSizes.width; + minCanvasHeight = _getAdjustedSizes.height; + canvasData.minWidth = minCanvasWidth; + canvasData.minHeight = minCanvasHeight; + canvasData.maxWidth = Infinity; + canvasData.maxHeight = Infinity; + } + if (positionLimited) { + if (viewMode > (cropped ? 0 : 1)) { + var newCanvasLeft = containerData.width - canvasData.width; + var newCanvasTop = containerData.height - canvasData.height; + canvasData.minLeft = Math.min(0, newCanvasLeft); + canvasData.minTop = Math.min(0, newCanvasTop); + canvasData.maxLeft = Math.max(0, newCanvasLeft); + canvasData.maxTop = Math.max(0, newCanvasTop); + if (cropped && this.limited) { + canvasData.minLeft = Math.min(cropBoxData.left, cropBoxData.left + (cropBoxData.width - canvasData.width)); + canvasData.minTop = Math.min(cropBoxData.top, cropBoxData.top + (cropBoxData.height - canvasData.height)); + canvasData.maxLeft = cropBoxData.left; + canvasData.maxTop = cropBoxData.top; + if (viewMode === 2) { + if (canvasData.width >= containerData.width) { + canvasData.minLeft = Math.min(0, newCanvasLeft); + canvasData.maxLeft = Math.max(0, newCanvasLeft); + } + if (canvasData.height >= containerData.height) { + canvasData.minTop = Math.min(0, newCanvasTop); + canvasData.maxTop = Math.max(0, newCanvasTop); + } + } + } + } else { + canvasData.minLeft = -canvasData.width; + canvasData.minTop = -canvasData.height; + canvasData.maxLeft = containerData.width; + canvasData.maxTop = containerData.height; + } + } + }, + renderCanvas: function renderCanvas(changed, transformed) { + var canvasData = this.canvasData, + imageData = this.imageData; + if (transformed) { + var _getRotatedSizes = getRotatedSizes({ + width: imageData.naturalWidth * Math.abs(imageData.scaleX || 1), + height: imageData.naturalHeight * Math.abs(imageData.scaleY || 1), + degree: imageData.rotate || 0 + }), + naturalWidth = _getRotatedSizes.width, + naturalHeight = _getRotatedSizes.height; + var width = canvasData.width * (naturalWidth / canvasData.naturalWidth); + var height = canvasData.height * (naturalHeight / canvasData.naturalHeight); + canvasData.left -= (width - canvasData.width) / 2; + canvasData.top -= (height - canvasData.height) / 2; + canvasData.width = width; + canvasData.height = height; + canvasData.aspectRatio = naturalWidth / naturalHeight; + canvasData.naturalWidth = naturalWidth; + canvasData.naturalHeight = naturalHeight; + this.limitCanvas(true, false); + } + if (canvasData.width > canvasData.maxWidth || canvasData.width < canvasData.minWidth) { + canvasData.left = canvasData.oldLeft; + } + if (canvasData.height > canvasData.maxHeight || canvasData.height < canvasData.minHeight) { + canvasData.top = canvasData.oldTop; + } + canvasData.width = Math.min(Math.max(canvasData.width, canvasData.minWidth), canvasData.maxWidth); + canvasData.height = Math.min(Math.max(canvasData.height, canvasData.minHeight), canvasData.maxHeight); + this.limitCanvas(false, true); + canvasData.left = Math.min(Math.max(canvasData.left, canvasData.minLeft), canvasData.maxLeft); + canvasData.top = Math.min(Math.max(canvasData.top, canvasData.minTop), canvasData.maxTop); + canvasData.oldLeft = canvasData.left; + canvasData.oldTop = canvasData.top; + setStyle(this.canvas, assign({ + width: canvasData.width, + height: canvasData.height + }, getTransforms({ + translateX: canvasData.left, + translateY: canvasData.top + }))); + this.renderImage(changed); + if (this.cropped && this.limited) { + this.limitCropBox(true, true); + } + }, + renderImage: function renderImage(changed) { + var canvasData = this.canvasData, + imageData = this.imageData; + var width = imageData.naturalWidth * (canvasData.width / canvasData.naturalWidth); + var height = imageData.naturalHeight * (canvasData.height / canvasData.naturalHeight); + assign(imageData, { + width: width, + height: height, + left: (canvasData.width - width) / 2, + top: (canvasData.height - height) / 2 + }); + setStyle(this.image, assign({ + width: imageData.width, + height: imageData.height + }, getTransforms(assign({ + translateX: imageData.left, + translateY: imageData.top + }, imageData)))); + if (changed) { + this.output(); + } + }, + initCropBox: function initCropBox() { + var options = this.options, + canvasData = this.canvasData; + var aspectRatio = options.aspectRatio || options.initialAspectRatio; + var autoCropArea = Number(options.autoCropArea) || 0.8; + var cropBoxData = { + width: canvasData.width, + height: canvasData.height + }; + if (aspectRatio) { + if (canvasData.height * aspectRatio > canvasData.width) { + cropBoxData.height = cropBoxData.width / aspectRatio; + } else { + cropBoxData.width = cropBoxData.height * aspectRatio; + } + } + this.cropBoxData = cropBoxData; + this.limitCropBox(true, true); + + // Initialize auto crop area + cropBoxData.width = Math.min(Math.max(cropBoxData.width, cropBoxData.minWidth), cropBoxData.maxWidth); + cropBoxData.height = Math.min(Math.max(cropBoxData.height, cropBoxData.minHeight), cropBoxData.maxHeight); + + // The width/height of auto crop area must large than "minWidth/Height" + cropBoxData.width = Math.max(cropBoxData.minWidth, cropBoxData.width * autoCropArea); + cropBoxData.height = Math.max(cropBoxData.minHeight, cropBoxData.height * autoCropArea); + cropBoxData.left = canvasData.left + (canvasData.width - cropBoxData.width) / 2; + cropBoxData.top = canvasData.top + (canvasData.height - cropBoxData.height) / 2; + cropBoxData.oldLeft = cropBoxData.left; + cropBoxData.oldTop = cropBoxData.top; + this.initialCropBoxData = assign({}, cropBoxData); + }, + limitCropBox: function limitCropBox(sizeLimited, positionLimited) { + var options = this.options, + containerData = this.containerData, + canvasData = this.canvasData, + cropBoxData = this.cropBoxData, + limited = this.limited; + var aspectRatio = options.aspectRatio; + if (sizeLimited) { + var minCropBoxWidth = Number(options.minCropBoxWidth) || 0; + var minCropBoxHeight = Number(options.minCropBoxHeight) || 0; + var maxCropBoxWidth = limited ? Math.min(containerData.width, canvasData.width, canvasData.width + canvasData.left, containerData.width - canvasData.left) : containerData.width; + var maxCropBoxHeight = limited ? Math.min(containerData.height, canvasData.height, canvasData.height + canvasData.top, containerData.height - canvasData.top) : containerData.height; + + // The min/maxCropBoxWidth/Height must be less than container's width/height + minCropBoxWidth = Math.min(minCropBoxWidth, containerData.width); + minCropBoxHeight = Math.min(minCropBoxHeight, containerData.height); + if (aspectRatio) { + if (minCropBoxWidth && minCropBoxHeight) { + if (minCropBoxHeight * aspectRatio > minCropBoxWidth) { + minCropBoxHeight = minCropBoxWidth / aspectRatio; + } else { + minCropBoxWidth = minCropBoxHeight * aspectRatio; + } + } else if (minCropBoxWidth) { + minCropBoxHeight = minCropBoxWidth / aspectRatio; + } else if (minCropBoxHeight) { + minCropBoxWidth = minCropBoxHeight * aspectRatio; + } + if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) { + maxCropBoxHeight = maxCropBoxWidth / aspectRatio; + } else { + maxCropBoxWidth = maxCropBoxHeight * aspectRatio; + } + } + + // The minWidth/Height must be less than maxWidth/Height + cropBoxData.minWidth = Math.min(minCropBoxWidth, maxCropBoxWidth); + cropBoxData.minHeight = Math.min(minCropBoxHeight, maxCropBoxHeight); + cropBoxData.maxWidth = maxCropBoxWidth; + cropBoxData.maxHeight = maxCropBoxHeight; + } + if (positionLimited) { + if (limited) { + cropBoxData.minLeft = Math.max(0, canvasData.left); + cropBoxData.minTop = Math.max(0, canvasData.top); + cropBoxData.maxLeft = Math.min(containerData.width, canvasData.left + canvasData.width) - cropBoxData.width; + cropBoxData.maxTop = Math.min(containerData.height, canvasData.top + canvasData.height) - cropBoxData.height; + } else { + cropBoxData.minLeft = 0; + cropBoxData.minTop = 0; + cropBoxData.maxLeft = containerData.width - cropBoxData.width; + cropBoxData.maxTop = containerData.height - cropBoxData.height; + } + } + }, + renderCropBox: function renderCropBox() { + var options = this.options, + containerData = this.containerData, + cropBoxData = this.cropBoxData; + if (cropBoxData.width > cropBoxData.maxWidth || cropBoxData.width < cropBoxData.minWidth) { + cropBoxData.left = cropBoxData.oldLeft; + } + if (cropBoxData.height > cropBoxData.maxHeight || cropBoxData.height < cropBoxData.minHeight) { + cropBoxData.top = cropBoxData.oldTop; + } + cropBoxData.width = Math.min(Math.max(cropBoxData.width, cropBoxData.minWidth), cropBoxData.maxWidth); + cropBoxData.height = Math.min(Math.max(cropBoxData.height, cropBoxData.minHeight), cropBoxData.maxHeight); + this.limitCropBox(false, true); + cropBoxData.left = Math.min(Math.max(cropBoxData.left, cropBoxData.minLeft), cropBoxData.maxLeft); + cropBoxData.top = Math.min(Math.max(cropBoxData.top, cropBoxData.minTop), cropBoxData.maxTop); + cropBoxData.oldLeft = cropBoxData.left; + cropBoxData.oldTop = cropBoxData.top; + if (options.movable && options.cropBoxMovable) { + // Turn to move the canvas when the crop box is equal to the container + setData(this.face, DATA_ACTION, cropBoxData.width >= containerData.width && cropBoxData.height >= containerData.height ? ACTION_MOVE : ACTION_ALL); + } + setStyle(this.cropBox, assign({ + width: cropBoxData.width, + height: cropBoxData.height + }, getTransforms({ + translateX: cropBoxData.left, + translateY: cropBoxData.top + }))); + if (this.cropped && this.limited) { + this.limitCanvas(true, true); + } + if (!this.disabled) { + this.output(); + } + }, + output: function output() { + this.preview(); + dispatchEvent(this.element, EVENT_CROP, this.getData()); + } + }; + + var preview = { + initPreview: function initPreview() { + var element = this.element, + crossOrigin = this.crossOrigin; + var preview = this.options.preview; + var url = crossOrigin ? this.crossOriginUrl : this.url; + var alt = element.alt || 'The image to preview'; + var image = document.createElement('img'); + if (crossOrigin) { + image.crossOrigin = crossOrigin; + } + image.src = url; + image.alt = alt; + this.viewBox.appendChild(image); + this.viewBoxImage = image; + if (!preview) { + return; + } + var previews = preview; + if (typeof preview === 'string') { + previews = element.ownerDocument.querySelectorAll(preview); + } else if (preview.querySelector) { + previews = [preview]; + } + this.previews = previews; + forEach(previews, function (el) { + var img = document.createElement('img'); + + // Save the original size for recover + setData(el, DATA_PREVIEW, { + width: el.offsetWidth, + height: el.offsetHeight, + html: el.innerHTML + }); + if (crossOrigin) { + img.crossOrigin = crossOrigin; + } + img.src = url; + img.alt = alt; + + /** + * Override img element styles + * Add `display:block` to avoid margin top issue + * Add `height:auto` to override `height` attribute on IE8 + * (Occur only when margin-top <= -height) + */ + img.style.cssText = 'display:block;' + 'width:100%;' + 'height:auto;' + 'min-width:0!important;' + 'min-height:0!important;' + 'max-width:none!important;' + 'max-height:none!important;' + 'image-orientation:0deg!important;"'; + el.innerHTML = ''; + el.appendChild(img); + }); + }, + resetPreview: function resetPreview() { + forEach(this.previews, function (element) { + var data = getData(element, DATA_PREVIEW); + setStyle(element, { + width: data.width, + height: data.height + }); + element.innerHTML = data.html; + removeData(element, DATA_PREVIEW); + }); + }, + preview: function preview() { + var imageData = this.imageData, + canvasData = this.canvasData, + cropBoxData = this.cropBoxData; + var cropBoxWidth = cropBoxData.width, + cropBoxHeight = cropBoxData.height; + var width = imageData.width, + height = imageData.height; + var left = cropBoxData.left - canvasData.left - imageData.left; + var top = cropBoxData.top - canvasData.top - imageData.top; + if (!this.cropped || this.disabled) { + return; + } + setStyle(this.viewBoxImage, assign({ + width: width, + height: height + }, getTransforms(assign({ + translateX: -left, + translateY: -top + }, imageData)))); + forEach(this.previews, function (element) { + var data = getData(element, DATA_PREVIEW); + var originalWidth = data.width; + var originalHeight = data.height; + var newWidth = originalWidth; + var newHeight = originalHeight; + var ratio = 1; + if (cropBoxWidth) { + ratio = originalWidth / cropBoxWidth; + newHeight = cropBoxHeight * ratio; + } + if (cropBoxHeight && newHeight > originalHeight) { + ratio = originalHeight / cropBoxHeight; + newWidth = cropBoxWidth * ratio; + newHeight = originalHeight; + } + setStyle(element, { + width: newWidth, + height: newHeight + }); + setStyle(element.getElementsByTagName('img')[0], assign({ + width: width * ratio, + height: height * ratio + }, getTransforms(assign({ + translateX: -left * ratio, + translateY: -top * ratio + }, imageData)))); + }); + } + }; + + var events = { + bind: function bind() { + var element = this.element, + options = this.options, + cropper = this.cropper; + if (isFunction(options.cropstart)) { + addListener(element, EVENT_CROP_START, options.cropstart); + } + if (isFunction(options.cropmove)) { + addListener(element, EVENT_CROP_MOVE, options.cropmove); + } + if (isFunction(options.cropend)) { + addListener(element, EVENT_CROP_END, options.cropend); + } + if (isFunction(options.crop)) { + addListener(element, EVENT_CROP, options.crop); + } + if (isFunction(options.zoom)) { + addListener(element, EVENT_ZOOM, options.zoom); + } + addListener(cropper, EVENT_POINTER_DOWN, this.onCropStart = this.cropStart.bind(this)); + if (options.zoomable && options.zoomOnWheel) { + addListener(cropper, EVENT_WHEEL, this.onWheel = this.wheel.bind(this), { + passive: false, + capture: true + }); + } + if (options.toggleDragModeOnDblclick) { + addListener(cropper, EVENT_DBLCLICK, this.onDblclick = this.dblclick.bind(this)); + } + addListener(element.ownerDocument, EVENT_POINTER_MOVE, this.onCropMove = this.cropMove.bind(this)); + addListener(element.ownerDocument, EVENT_POINTER_UP, this.onCropEnd = this.cropEnd.bind(this)); + if (options.responsive) { + addListener(window, EVENT_RESIZE, this.onResize = this.resize.bind(this)); + } + }, + unbind: function unbind() { + var element = this.element, + options = this.options, + cropper = this.cropper; + if (isFunction(options.cropstart)) { + removeListener(element, EVENT_CROP_START, options.cropstart); + } + if (isFunction(options.cropmove)) { + removeListener(element, EVENT_CROP_MOVE, options.cropmove); + } + if (isFunction(options.cropend)) { + removeListener(element, EVENT_CROP_END, options.cropend); + } + if (isFunction(options.crop)) { + removeListener(element, EVENT_CROP, options.crop); + } + if (isFunction(options.zoom)) { + removeListener(element, EVENT_ZOOM, options.zoom); + } + removeListener(cropper, EVENT_POINTER_DOWN, this.onCropStart); + if (options.zoomable && options.zoomOnWheel) { + removeListener(cropper, EVENT_WHEEL, this.onWheel, { + passive: false, + capture: true + }); + } + if (options.toggleDragModeOnDblclick) { + removeListener(cropper, EVENT_DBLCLICK, this.onDblclick); + } + removeListener(element.ownerDocument, EVENT_POINTER_MOVE, this.onCropMove); + removeListener(element.ownerDocument, EVENT_POINTER_UP, this.onCropEnd); + if (options.responsive) { + removeListener(window, EVENT_RESIZE, this.onResize); + } + } + }; + + var handlers = { + resize: function resize() { + if (this.disabled) { + return; + } + var options = this.options, + container = this.container, + containerData = this.containerData; + var ratioX = container.offsetWidth / containerData.width; + var ratioY = container.offsetHeight / containerData.height; + var ratio = Math.abs(ratioX - 1) > Math.abs(ratioY - 1) ? ratioX : ratioY; + + // Resize when width changed or height changed + if (ratio !== 1) { + var canvasData; + var cropBoxData; + if (options.restore) { + canvasData = this.getCanvasData(); + cropBoxData = this.getCropBoxData(); + } + this.render(); + if (options.restore) { + this.setCanvasData(forEach(canvasData, function (n, i) { + canvasData[i] = n * ratio; + })); + this.setCropBoxData(forEach(cropBoxData, function (n, i) { + cropBoxData[i] = n * ratio; + })); + } + } + }, + dblclick: function dblclick() { + if (this.disabled || this.options.dragMode === DRAG_MODE_NONE) { + return; + } + this.setDragMode(hasClass(this.dragBox, CLASS_CROP) ? DRAG_MODE_MOVE : DRAG_MODE_CROP); + }, + wheel: function wheel(event) { + var _this = this; + var ratio = Number(this.options.wheelZoomRatio) || 0.1; + var delta = 1; + if (this.disabled) { + return; + } + event.preventDefault(); + + // Limit wheel speed to prevent zoom too fast (#21) + if (this.wheeling) { + return; + } + this.wheeling = true; + setTimeout(function () { + _this.wheeling = false; + }, 50); + if (event.deltaY) { + delta = event.deltaY > 0 ? 1 : -1; + } else if (event.wheelDelta) { + delta = -event.wheelDelta / 120; + } else if (event.detail) { + delta = event.detail > 0 ? 1 : -1; + } + this.zoom(-delta * ratio, event); + }, + cropStart: function cropStart(event) { + var buttons = event.buttons, + button = event.button; + if (this.disabled + + // Handle mouse event and pointer event and ignore touch event + || (event.type === 'mousedown' || event.type === 'pointerdown' && event.pointerType === 'mouse') && ( + // No primary button (Usually the left button) + isNumber(buttons) && buttons !== 1 || isNumber(button) && button !== 0 + + // Open context menu + || event.ctrlKey)) { + return; + } + var options = this.options, + pointers = this.pointers; + var action; + if (event.changedTouches) { + // Handle touch event + forEach(event.changedTouches, function (touch) { + pointers[touch.identifier] = getPointer(touch); + }); + } else { + // Handle mouse event and pointer event + pointers[event.pointerId || 0] = getPointer(event); + } + if (Object.keys(pointers).length > 1 && options.zoomable && options.zoomOnTouch) { + action = ACTION_ZOOM; + } else { + action = getData(event.target, DATA_ACTION); + } + if (!REGEXP_ACTIONS.test(action)) { + return; + } + if (dispatchEvent(this.element, EVENT_CROP_START, { + originalEvent: event, + action: action + }) === false) { + return; + } + + // This line is required for preventing page zooming in iOS browsers + event.preventDefault(); + this.action = action; + this.cropping = false; + if (action === ACTION_CROP) { + this.cropping = true; + addClass(this.dragBox, CLASS_MODAL); + } + }, + cropMove: function cropMove(event) { + var action = this.action; + if (this.disabled || !action) { + return; + } + var pointers = this.pointers; + event.preventDefault(); + if (dispatchEvent(this.element, EVENT_CROP_MOVE, { + originalEvent: event, + action: action + }) === false) { + return; + } + if (event.changedTouches) { + forEach(event.changedTouches, function (touch) { + // The first parameter should not be undefined (#432) + assign(pointers[touch.identifier] || {}, getPointer(touch, true)); + }); + } else { + assign(pointers[event.pointerId || 0] || {}, getPointer(event, true)); + } + this.change(event); + }, + cropEnd: function cropEnd(event) { + if (this.disabled) { + return; + } + var action = this.action, + pointers = this.pointers; + if (event.changedTouches) { + forEach(event.changedTouches, function (touch) { + delete pointers[touch.identifier]; + }); + } else { + delete pointers[event.pointerId || 0]; + } + if (!action) { + return; + } + event.preventDefault(); + if (!Object.keys(pointers).length) { + this.action = ''; + } + if (this.cropping) { + this.cropping = false; + toggleClass(this.dragBox, CLASS_MODAL, this.cropped && this.options.modal); + } + dispatchEvent(this.element, EVENT_CROP_END, { + originalEvent: event, + action: action + }); + } + }; + + var change = { + change: function change(event) { + var options = this.options, + canvasData = this.canvasData, + containerData = this.containerData, + cropBoxData = this.cropBoxData, + pointers = this.pointers; + var action = this.action; + var aspectRatio = options.aspectRatio; + var left = cropBoxData.left, + top = cropBoxData.top, + width = cropBoxData.width, + height = cropBoxData.height; + var right = left + width; + var bottom = top + height; + var minLeft = 0; + var minTop = 0; + var maxWidth = containerData.width; + var maxHeight = containerData.height; + var renderable = true; + var offset; + + // Locking aspect ratio in "free mode" by holding shift key + if (!aspectRatio && event.shiftKey) { + aspectRatio = width && height ? width / height : 1; + } + if (this.limited) { + minLeft = cropBoxData.minLeft; + minTop = cropBoxData.minTop; + maxWidth = minLeft + Math.min(containerData.width, canvasData.width, canvasData.left + canvasData.width); + maxHeight = minTop + Math.min(containerData.height, canvasData.height, canvasData.top + canvasData.height); + } + var pointer = pointers[Object.keys(pointers)[0]]; + var range = { + x: pointer.endX - pointer.startX, + y: pointer.endY - pointer.startY + }; + var check = function check(side) { + switch (side) { + case ACTION_EAST: + if (right + range.x > maxWidth) { + range.x = maxWidth - right; + } + break; + case ACTION_WEST: + if (left + range.x < minLeft) { + range.x = minLeft - left; + } + break; + case ACTION_NORTH: + if (top + range.y < minTop) { + range.y = minTop - top; + } + break; + case ACTION_SOUTH: + if (bottom + range.y > maxHeight) { + range.y = maxHeight - bottom; + } + break; + } + }; + switch (action) { + // Move crop box + case ACTION_ALL: + left += range.x; + top += range.y; + break; + + // Resize crop box + case ACTION_EAST: + if (range.x >= 0 && (right >= maxWidth || aspectRatio && (top <= minTop || bottom >= maxHeight))) { + renderable = false; + break; + } + check(ACTION_EAST); + width += range.x; + if (width < 0) { + action = ACTION_WEST; + width = -width; + left -= width; + } + if (aspectRatio) { + height = width / aspectRatio; + top += (cropBoxData.height - height) / 2; + } + break; + case ACTION_NORTH: + if (range.y <= 0 && (top <= minTop || aspectRatio && (left <= minLeft || right >= maxWidth))) { + renderable = false; + break; + } + check(ACTION_NORTH); + height -= range.y; + top += range.y; + if (height < 0) { + action = ACTION_SOUTH; + height = -height; + top -= height; + } + if (aspectRatio) { + width = height * aspectRatio; + left += (cropBoxData.width - width) / 2; + } + break; + case ACTION_WEST: + if (range.x <= 0 && (left <= minLeft || aspectRatio && (top <= minTop || bottom >= maxHeight))) { + renderable = false; + break; + } + check(ACTION_WEST); + width -= range.x; + left += range.x; + if (width < 0) { + action = ACTION_EAST; + width = -width; + left -= width; + } + if (aspectRatio) { + height = width / aspectRatio; + top += (cropBoxData.height - height) / 2; + } + break; + case ACTION_SOUTH: + if (range.y >= 0 && (bottom >= maxHeight || aspectRatio && (left <= minLeft || right >= maxWidth))) { + renderable = false; + break; + } + check(ACTION_SOUTH); + height += range.y; + if (height < 0) { + action = ACTION_NORTH; + height = -height; + top -= height; + } + if (aspectRatio) { + width = height * aspectRatio; + left += (cropBoxData.width - width) / 2; + } + break; + case ACTION_NORTH_EAST: + if (aspectRatio) { + if (range.y <= 0 && (top <= minTop || right >= maxWidth)) { + renderable = false; + break; + } + check(ACTION_NORTH); + height -= range.y; + top += range.y; + width = height * aspectRatio; + } else { + check(ACTION_NORTH); + check(ACTION_EAST); + if (range.x >= 0) { + if (right < maxWidth) { + width += range.x; + } else if (range.y <= 0 && top <= minTop) { + renderable = false; + } + } else { + width += range.x; + } + if (range.y <= 0) { + if (top > minTop) { + height -= range.y; + top += range.y; + } + } else { + height -= range.y; + top += range.y; + } + } + if (width < 0 && height < 0) { + action = ACTION_SOUTH_WEST; + height = -height; + width = -width; + top -= height; + left -= width; + } else if (width < 0) { + action = ACTION_NORTH_WEST; + width = -width; + left -= width; + } else if (height < 0) { + action = ACTION_SOUTH_EAST; + height = -height; + top -= height; + } + break; + case ACTION_NORTH_WEST: + if (aspectRatio) { + if (range.y <= 0 && (top <= minTop || left <= minLeft)) { + renderable = false; + break; + } + check(ACTION_NORTH); + height -= range.y; + top += range.y; + width = height * aspectRatio; + left += cropBoxData.width - width; + } else { + check(ACTION_NORTH); + check(ACTION_WEST); + if (range.x <= 0) { + if (left > minLeft) { + width -= range.x; + left += range.x; + } else if (range.y <= 0 && top <= minTop) { + renderable = false; + } + } else { + width -= range.x; + left += range.x; + } + if (range.y <= 0) { + if (top > minTop) { + height -= range.y; + top += range.y; + } + } else { + height -= range.y; + top += range.y; + } + } + if (width < 0 && height < 0) { + action = ACTION_SOUTH_EAST; + height = -height; + width = -width; + top -= height; + left -= width; + } else if (width < 0) { + action = ACTION_NORTH_EAST; + width = -width; + left -= width; + } else if (height < 0) { + action = ACTION_SOUTH_WEST; + height = -height; + top -= height; + } + break; + case ACTION_SOUTH_WEST: + if (aspectRatio) { + if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) { + renderable = false; + break; + } + check(ACTION_WEST); + width -= range.x; + left += range.x; + height = width / aspectRatio; + } else { + check(ACTION_SOUTH); + check(ACTION_WEST); + if (range.x <= 0) { + if (left > minLeft) { + width -= range.x; + left += range.x; + } else if (range.y >= 0 && bottom >= maxHeight) { + renderable = false; + } + } else { + width -= range.x; + left += range.x; + } + if (range.y >= 0) { + if (bottom < maxHeight) { + height += range.y; + } + } else { + height += range.y; + } + } + if (width < 0 && height < 0) { + action = ACTION_NORTH_EAST; + height = -height; + width = -width; + top -= height; + left -= width; + } else if (width < 0) { + action = ACTION_SOUTH_EAST; + width = -width; + left -= width; + } else if (height < 0) { + action = ACTION_NORTH_WEST; + height = -height; + top -= height; + } + break; + case ACTION_SOUTH_EAST: + if (aspectRatio) { + if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) { + renderable = false; + break; + } + check(ACTION_EAST); + width += range.x; + height = width / aspectRatio; + } else { + check(ACTION_SOUTH); + check(ACTION_EAST); + if (range.x >= 0) { + if (right < maxWidth) { + width += range.x; + } else if (range.y >= 0 && bottom >= maxHeight) { + renderable = false; + } + } else { + width += range.x; + } + if (range.y >= 0) { + if (bottom < maxHeight) { + height += range.y; + } + } else { + height += range.y; + } + } + if (width < 0 && height < 0) { + action = ACTION_NORTH_WEST; + height = -height; + width = -width; + top -= height; + left -= width; + } else if (width < 0) { + action = ACTION_SOUTH_WEST; + width = -width; + left -= width; + } else if (height < 0) { + action = ACTION_NORTH_EAST; + height = -height; + top -= height; + } + break; + + // Move canvas + case ACTION_MOVE: + this.move(range.x, range.y); + renderable = false; + break; + + // Zoom canvas + case ACTION_ZOOM: + this.zoom(getMaxZoomRatio(pointers), event); + renderable = false; + break; + + // Create crop box + case ACTION_CROP: + if (!range.x || !range.y) { + renderable = false; + break; + } + offset = getOffset(this.cropper); + left = pointer.startX - offset.left; + top = pointer.startY - offset.top; + width = cropBoxData.minWidth; + height = cropBoxData.minHeight; + if (range.x > 0) { + action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST; + } else if (range.x < 0) { + left -= width; + action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST; + } + if (range.y < 0) { + top -= height; + } + + // Show the crop box if is hidden + if (!this.cropped) { + removeClass(this.cropBox, CLASS_HIDDEN); + this.cropped = true; + if (this.limited) { + this.limitCropBox(true, true); + } + } + break; + } + if (renderable) { + cropBoxData.width = width; + cropBoxData.height = height; + cropBoxData.left = left; + cropBoxData.top = top; + this.action = action; + this.renderCropBox(); + } + + // Override + forEach(pointers, function (p) { + p.startX = p.endX; + p.startY = p.endY; + }); + } + }; + + var methods = { + // Show the crop box manually + crop: function crop() { + if (this.ready && !this.cropped && !this.disabled) { + this.cropped = true; + this.limitCropBox(true, true); + if (this.options.modal) { + addClass(this.dragBox, CLASS_MODAL); + } + removeClass(this.cropBox, CLASS_HIDDEN); + this.setCropBoxData(this.initialCropBoxData); + } + return this; + }, + // Reset the image and crop box to their initial states + reset: function reset() { + if (this.ready && !this.disabled) { + this.imageData = assign({}, this.initialImageData); + this.canvasData = assign({}, this.initialCanvasData); + this.cropBoxData = assign({}, this.initialCropBoxData); + this.renderCanvas(); + if (this.cropped) { + this.renderCropBox(); + } + } + return this; + }, + // Clear the crop box + clear: function clear() { + if (this.cropped && !this.disabled) { + assign(this.cropBoxData, { + left: 0, + top: 0, + width: 0, + height: 0 + }); + this.cropped = false; + this.renderCropBox(); + this.limitCanvas(true, true); + + // Render canvas after crop box rendered + this.renderCanvas(); + removeClass(this.dragBox, CLASS_MODAL); + addClass(this.cropBox, CLASS_HIDDEN); + } + return this; + }, + /** + * Replace the image's src and rebuild the cropper + * @param {string} url - The new URL. + * @param {boolean} [hasSameSize] - Indicate if the new image has the same size as the old one. + * @returns {Cropper} this + */ + replace: function replace(url) { + var hasSameSize = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false; + if (!this.disabled && url) { + if (this.isImg) { + this.element.src = url; + } + if (hasSameSize) { + this.url = url; + this.image.src = url; + if (this.ready) { + this.viewBoxImage.src = url; + forEach(this.previews, function (element) { + element.getElementsByTagName('img')[0].src = url; + }); + } + } else { + if (this.isImg) { + this.replaced = true; + } + this.options.data = null; + this.uncreate(); + this.load(url); + } + } + return this; + }, + // Enable (unfreeze) the cropper + enable: function enable() { + if (this.ready && this.disabled) { + this.disabled = false; + removeClass(this.cropper, CLASS_DISABLED); + } + return this; + }, + // Disable (freeze) the cropper + disable: function disable() { + if (this.ready && !this.disabled) { + this.disabled = true; + addClass(this.cropper, CLASS_DISABLED); + } + return this; + }, + /** + * Destroy the cropper and remove the instance from the image + * @returns {Cropper} this + */ + destroy: function destroy() { + var element = this.element; + if (!element[NAMESPACE]) { + return this; + } + element[NAMESPACE] = undefined; + if (this.isImg && this.replaced) { + element.src = this.originalUrl; + } + this.uncreate(); + return this; + }, + /** + * Move the canvas with relative offsets + * @param {number} offsetX - The relative offset distance on the x-axis. + * @param {number} [offsetY=offsetX] - The relative offset distance on the y-axis. + * @returns {Cropper} this + */ + move: function move(offsetX) { + var offsetY = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : offsetX; + var _this$canvasData = this.canvasData, + left = _this$canvasData.left, + top = _this$canvasData.top; + return this.moveTo(isUndefined(offsetX) ? offsetX : left + Number(offsetX), isUndefined(offsetY) ? offsetY : top + Number(offsetY)); + }, + /** + * Move the canvas to an absolute point + * @param {number} x - The x-axis coordinate. + * @param {number} [y=x] - The y-axis coordinate. + * @returns {Cropper} this + */ + moveTo: function moveTo(x) { + var y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : x; + var canvasData = this.canvasData; + var changed = false; + x = Number(x); + y = Number(y); + if (this.ready && !this.disabled && this.options.movable) { + if (isNumber(x)) { + canvasData.left = x; + changed = true; + } + if (isNumber(y)) { + canvasData.top = y; + changed = true; + } + if (changed) { + this.renderCanvas(true); + } + } + return this; + }, + /** + * Zoom the canvas with a relative ratio + * @param {number} ratio - The target ratio. + * @param {Event} _originalEvent - The original event if any. + * @returns {Cropper} this + */ + zoom: function zoom(ratio, _originalEvent) { + var canvasData = this.canvasData; + ratio = Number(ratio); + if (ratio < 0) { + ratio = 1 / (1 - ratio); + } else { + ratio = 1 + ratio; + } + return this.zoomTo(canvasData.width * ratio / canvasData.naturalWidth, null, _originalEvent); + }, + /** + * Zoom the canvas to an absolute ratio + * @param {number} ratio - The target ratio. + * @param {Object} pivot - The zoom pivot point coordinate. + * @param {Event} _originalEvent - The original event if any. + * @returns {Cropper} this + */ + zoomTo: function zoomTo(ratio, pivot, _originalEvent) { + var options = this.options, + canvasData = this.canvasData; + var width = canvasData.width, + height = canvasData.height, + naturalWidth = canvasData.naturalWidth, + naturalHeight = canvasData.naturalHeight; + ratio = Number(ratio); + if (ratio >= 0 && this.ready && !this.disabled && options.zoomable) { + var newWidth = naturalWidth * ratio; + var newHeight = naturalHeight * ratio; + if (dispatchEvent(this.element, EVENT_ZOOM, { + ratio: ratio, + oldRatio: width / naturalWidth, + originalEvent: _originalEvent + }) === false) { + return this; + } + if (_originalEvent) { + var pointers = this.pointers; + var offset = getOffset(this.cropper); + var center = pointers && Object.keys(pointers).length ? getPointersCenter(pointers) : { + pageX: _originalEvent.pageX, + pageY: _originalEvent.pageY + }; + + // Zoom from the triggering point of the event + canvasData.left -= (newWidth - width) * ((center.pageX - offset.left - canvasData.left) / width); + canvasData.top -= (newHeight - height) * ((center.pageY - offset.top - canvasData.top) / height); + } else if (isPlainObject(pivot) && isNumber(pivot.x) && isNumber(pivot.y)) { + canvasData.left -= (newWidth - width) * ((pivot.x - canvasData.left) / width); + canvasData.top -= (newHeight - height) * ((pivot.y - canvasData.top) / height); + } else { + // Zoom from the center of the canvas + canvasData.left -= (newWidth - width) / 2; + canvasData.top -= (newHeight - height) / 2; + } + canvasData.width = newWidth; + canvasData.height = newHeight; + this.renderCanvas(true); + } + return this; + }, + /** + * Rotate the canvas with a relative degree + * @param {number} degree - The rotate degree. + * @returns {Cropper} this + */ + rotate: function rotate(degree) { + return this.rotateTo((this.imageData.rotate || 0) + Number(degree)); + }, + /** + * Rotate the canvas to an absolute degree + * @param {number} degree - The rotate degree. + * @returns {Cropper} this + */ + rotateTo: function rotateTo(degree) { + degree = Number(degree); + if (isNumber(degree) && this.ready && !this.disabled && this.options.rotatable) { + this.imageData.rotate = degree % 360; + this.renderCanvas(true, true); + } + return this; + }, + /** + * Scale the image on the x-axis. + * @param {number} scaleX - The scale ratio on the x-axis. + * @returns {Cropper} this + */ + scaleX: function scaleX(_scaleX) { + var scaleY = this.imageData.scaleY; + return this.scale(_scaleX, isNumber(scaleY) ? scaleY : 1); + }, + /** + * Scale the image on the y-axis. + * @param {number} scaleY - The scale ratio on the y-axis. + * @returns {Cropper} this + */ + scaleY: function scaleY(_scaleY) { + var scaleX = this.imageData.scaleX; + return this.scale(isNumber(scaleX) ? scaleX : 1, _scaleY); + }, + /** + * Scale the image + * @param {number} scaleX - The scale ratio on the x-axis. + * @param {number} [scaleY=scaleX] - The scale ratio on the y-axis. + * @returns {Cropper} this + */ + scale: function scale(scaleX) { + var scaleY = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : scaleX; + var imageData = this.imageData; + var transformed = false; + scaleX = Number(scaleX); + scaleY = Number(scaleY); + if (this.ready && !this.disabled && this.options.scalable) { + if (isNumber(scaleX)) { + imageData.scaleX = scaleX; + transformed = true; + } + if (isNumber(scaleY)) { + imageData.scaleY = scaleY; + transformed = true; + } + if (transformed) { + this.renderCanvas(true, true); + } + } + return this; + }, + /** + * Get the cropped area position and size data (base on the original image) + * @param {boolean} [rounded=false] - Indicate if round the data values or not. + * @returns {Object} The result cropped data. + */ + getData: function getData() { + var rounded = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; + var options = this.options, + imageData = this.imageData, + canvasData = this.canvasData, + cropBoxData = this.cropBoxData; + var data; + if (this.ready && this.cropped) { + data = { + x: cropBoxData.left - canvasData.left, + y: cropBoxData.top - canvasData.top, + width: cropBoxData.width, + height: cropBoxData.height + }; + var ratio = imageData.width / imageData.naturalWidth; + forEach(data, function (n, i) { + data[i] = n / ratio; + }); + if (rounded) { + // In case rounding off leads to extra 1px in right or bottom border + // we should round the top-left corner and the dimension (#343). + var bottom = Math.round(data.y + data.height); + var right = Math.round(data.x + data.width); + data.x = Math.round(data.x); + data.y = Math.round(data.y); + data.width = right - data.x; + data.height = bottom - data.y; + } + } else { + data = { + x: 0, + y: 0, + width: 0, + height: 0 + }; + } + if (options.rotatable) { + data.rotate = imageData.rotate || 0; + } + if (options.scalable) { + data.scaleX = imageData.scaleX || 1; + data.scaleY = imageData.scaleY || 1; + } + return data; + }, + /** + * Set the cropped area position and size with new data + * @param {Object} data - The new data. + * @returns {Cropper} this + */ + setData: function setData(data) { + var options = this.options, + imageData = this.imageData, + canvasData = this.canvasData; + var cropBoxData = {}; + if (this.ready && !this.disabled && isPlainObject(data)) { + var transformed = false; + if (options.rotatable) { + if (isNumber(data.rotate) && data.rotate !== imageData.rotate) { + imageData.rotate = data.rotate; + transformed = true; + } + } + if (options.scalable) { + if (isNumber(data.scaleX) && data.scaleX !== imageData.scaleX) { + imageData.scaleX = data.scaleX; + transformed = true; + } + if (isNumber(data.scaleY) && data.scaleY !== imageData.scaleY) { + imageData.scaleY = data.scaleY; + transformed = true; + } + } + if (transformed) { + this.renderCanvas(true, true); + } + var ratio = imageData.width / imageData.naturalWidth; + if (isNumber(data.x)) { + cropBoxData.left = data.x * ratio + canvasData.left; + } + if (isNumber(data.y)) { + cropBoxData.top = data.y * ratio + canvasData.top; + } + if (isNumber(data.width)) { + cropBoxData.width = data.width * ratio; + } + if (isNumber(data.height)) { + cropBoxData.height = data.height * ratio; + } + this.setCropBoxData(cropBoxData); + } + return this; + }, + /** + * Get the container size data. + * @returns {Object} The result container data. + */ + getContainerData: function getContainerData() { + return this.ready ? assign({}, this.containerData) : {}; + }, + /** + * Get the image position and size data. + * @returns {Object} The result image data. + */ + getImageData: function getImageData() { + return this.sized ? assign({}, this.imageData) : {}; + }, + /** + * Get the canvas position and size data. + * @returns {Object} The result canvas data. + */ + getCanvasData: function getCanvasData() { + var canvasData = this.canvasData; + var data = {}; + if (this.ready) { + forEach(['left', 'top', 'width', 'height', 'naturalWidth', 'naturalHeight'], function (n) { + data[n] = canvasData[n]; + }); + } + return data; + }, + /** + * Set the canvas position and size with new data. + * @param {Object} data - The new canvas data. + * @returns {Cropper} this + */ + setCanvasData: function setCanvasData(data) { + var canvasData = this.canvasData; + var aspectRatio = canvasData.aspectRatio; + if (this.ready && !this.disabled && isPlainObject(data)) { + if (isNumber(data.left)) { + canvasData.left = data.left; + } + if (isNumber(data.top)) { + canvasData.top = data.top; + } + if (isNumber(data.width)) { + canvasData.width = data.width; + canvasData.height = data.width / aspectRatio; + } else if (isNumber(data.height)) { + canvasData.height = data.height; + canvasData.width = data.height * aspectRatio; + } + this.renderCanvas(true); + } + return this; + }, + /** + * Get the crop box position and size data. + * @returns {Object} The result crop box data. + */ + getCropBoxData: function getCropBoxData() { + var cropBoxData = this.cropBoxData; + var data; + if (this.ready && this.cropped) { + data = { + left: cropBoxData.left, + top: cropBoxData.top, + width: cropBoxData.width, + height: cropBoxData.height + }; + } + return data || {}; + }, + /** + * Set the crop box position and size with new data. + * @param {Object} data - The new crop box data. + * @returns {Cropper} this + */ + setCropBoxData: function setCropBoxData(data) { + var cropBoxData = this.cropBoxData; + var aspectRatio = this.options.aspectRatio; + var widthChanged; + var heightChanged; + if (this.ready && this.cropped && !this.disabled && isPlainObject(data)) { + if (isNumber(data.left)) { + cropBoxData.left = data.left; + } + if (isNumber(data.top)) { + cropBoxData.top = data.top; + } + if (isNumber(data.width) && data.width !== cropBoxData.width) { + widthChanged = true; + cropBoxData.width = data.width; + } + if (isNumber(data.height) && data.height !== cropBoxData.height) { + heightChanged = true; + cropBoxData.height = data.height; + } + if (aspectRatio) { + if (widthChanged) { + cropBoxData.height = cropBoxData.width / aspectRatio; + } else if (heightChanged) { + cropBoxData.width = cropBoxData.height * aspectRatio; + } + } + this.renderCropBox(); + } + return this; + }, + /** + * Get a canvas drawn the cropped image. + * @param {Object} [options={}] - The config options. + * @returns {HTMLCanvasElement} - The result canvas. + */ + getCroppedCanvas: function getCroppedCanvas() { + var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + if (!this.ready || !window.HTMLCanvasElement) { + return null; + } + var canvasData = this.canvasData; + var source = getSourceCanvas(this.image, this.imageData, canvasData, options); + + // Returns the source canvas if it is not cropped. + if (!this.cropped) { + return source; + } + var _this$getData = this.getData(options.rounded), + initialX = _this$getData.x, + initialY = _this$getData.y, + initialWidth = _this$getData.width, + initialHeight = _this$getData.height; + var ratio = source.width / Math.floor(canvasData.naturalWidth); + if (ratio !== 1) { + initialX *= ratio; + initialY *= ratio; + initialWidth *= ratio; + initialHeight *= ratio; + } + var aspectRatio = initialWidth / initialHeight; + var maxSizes = getAdjustedSizes({ + aspectRatio: aspectRatio, + width: options.maxWidth || Infinity, + height: options.maxHeight || Infinity + }); + var minSizes = getAdjustedSizes({ + aspectRatio: aspectRatio, + width: options.minWidth || 0, + height: options.minHeight || 0 + }, 'cover'); + var _getAdjustedSizes = getAdjustedSizes({ + aspectRatio: aspectRatio, + width: options.width || (ratio !== 1 ? source.width : initialWidth), + height: options.height || (ratio !== 1 ? source.height : initialHeight) + }), + width = _getAdjustedSizes.width, + height = _getAdjustedSizes.height; + width = Math.min(maxSizes.width, Math.max(minSizes.width, width)); + height = Math.min(maxSizes.height, Math.max(minSizes.height, height)); + var canvas = document.createElement('canvas'); + var context = canvas.getContext('2d'); + canvas.width = normalizeDecimalNumber(width); + canvas.height = normalizeDecimalNumber(height); + context.fillStyle = options.fillColor || 'transparent'; + context.fillRect(0, 0, width, height); + var _options$imageSmoothi = options.imageSmoothingEnabled, + imageSmoothingEnabled = _options$imageSmoothi === void 0 ? true : _options$imageSmoothi, + imageSmoothingQuality = options.imageSmoothingQuality; + context.imageSmoothingEnabled = imageSmoothingEnabled; + if (imageSmoothingQuality) { + context.imageSmoothingQuality = imageSmoothingQuality; + } + + // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage + var sourceWidth = source.width; + var sourceHeight = source.height; + + // Source canvas parameters + var srcX = initialX; + var srcY = initialY; + var srcWidth; + var srcHeight; + + // Destination canvas parameters + var dstX; + var dstY; + var dstWidth; + var dstHeight; + if (srcX <= -initialWidth || srcX > sourceWidth) { + srcX = 0; + srcWidth = 0; + dstX = 0; + dstWidth = 0; + } else if (srcX <= 0) { + dstX = -srcX; + srcX = 0; + srcWidth = Math.min(sourceWidth, initialWidth + srcX); + dstWidth = srcWidth; + } else if (srcX <= sourceWidth) { + dstX = 0; + srcWidth = Math.min(initialWidth, sourceWidth - srcX); + dstWidth = srcWidth; + } + if (srcWidth <= 0 || srcY <= -initialHeight || srcY > sourceHeight) { + srcY = 0; + srcHeight = 0; + dstY = 0; + dstHeight = 0; + } else if (srcY <= 0) { + dstY = -srcY; + srcY = 0; + srcHeight = Math.min(sourceHeight, initialHeight + srcY); + dstHeight = srcHeight; + } else if (srcY <= sourceHeight) { + dstY = 0; + srcHeight = Math.min(initialHeight, sourceHeight - srcY); + dstHeight = srcHeight; + } + var params = [srcX, srcY, srcWidth, srcHeight]; + + // Avoid "IndexSizeError" + if (dstWidth > 0 && dstHeight > 0) { + var scale = width / initialWidth; + params.push(dstX * scale, dstY * scale, dstWidth * scale, dstHeight * scale); + } + + // All the numerical parameters should be integer for `drawImage` + // https://github.com/fengyuanchen/cropper/issues/476 + context.drawImage.apply(context, [source].concat(_toConsumableArray(params.map(function (param) { + return Math.floor(normalizeDecimalNumber(param)); + })))); + return canvas; + }, + /** + * Change the aspect ratio of the crop box. + * @param {number} aspectRatio - The new aspect ratio. + * @returns {Cropper} this + */ + setAspectRatio: function setAspectRatio(aspectRatio) { + var options = this.options; + if (!this.disabled && !isUndefined(aspectRatio)) { + // 0 -> NaN + options.aspectRatio = Math.max(0, aspectRatio) || NaN; + if (this.ready) { + this.initCropBox(); + if (this.cropped) { + this.renderCropBox(); + } + } + } + return this; + }, + /** + * Change the drag mode. + * @param {string} mode - The new drag mode. + * @returns {Cropper} this + */ + setDragMode: function setDragMode(mode) { + var options = this.options, + dragBox = this.dragBox, + face = this.face; + if (this.ready && !this.disabled) { + var croppable = mode === DRAG_MODE_CROP; + var movable = options.movable && mode === DRAG_MODE_MOVE; + mode = croppable || movable ? mode : DRAG_MODE_NONE; + options.dragMode = mode; + setData(dragBox, DATA_ACTION, mode); + toggleClass(dragBox, CLASS_CROP, croppable); + toggleClass(dragBox, CLASS_MOVE, movable); + if (!options.cropBoxMovable) { + // Sync drag mode to crop box when it is not movable + setData(face, DATA_ACTION, mode); + toggleClass(face, CLASS_CROP, croppable); + toggleClass(face, CLASS_MOVE, movable); + } + } + return this; + } + }; + + var AnotherCropper = WINDOW.Cropper; + var Cropper = /*#__PURE__*/function () { + /** + * Create a new Cropper. + * @param {Element} element - The target element for cropping. + * @param {Object} [options={}] - The configuration options. + */ + function Cropper(element) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + _classCallCheck(this, Cropper); + if (!element || !REGEXP_TAG_NAME.test(element.tagName)) { + throw new Error('The first argument is required and must be an or element.'); + } + this.element = element; + this.options = assign({}, DEFAULTS, isPlainObject(options) && options); + this.cropped = false; + this.disabled = false; + this.pointers = {}; + this.ready = false; + this.reloading = false; + this.replaced = false; + this.sized = false; + this.sizing = false; + this.init(); + } + _createClass(Cropper, [{ + key: "init", + value: function init() { + var element = this.element; + var tagName = element.tagName.toLowerCase(); + var url; + if (element[NAMESPACE]) { + return; + } + element[NAMESPACE] = this; + if (tagName === 'img') { + this.isImg = true; + + // e.g.: "img/picture.jpg" + url = element.getAttribute('src') || ''; + this.originalUrl = url; + + // Stop when it's a blank image + if (!url) { + return; + } + + // e.g.: "https://example.com/img/picture.jpg" + url = element.src; + } else if (tagName === 'canvas' && window.HTMLCanvasElement) { + url = element.toDataURL(); + } + this.load(url); + } + }, { + key: "load", + value: function load(url) { + var _this = this; + if (!url) { + return; + } + this.url = url; + this.imageData = {}; + var element = this.element, + options = this.options; + if (!options.rotatable && !options.scalable) { + options.checkOrientation = false; + } + + // Only IE10+ supports Typed Arrays + if (!options.checkOrientation || !window.ArrayBuffer) { + this.clone(); + return; + } + + // Detect the mime type of the image directly if it is a Data URL + if (REGEXP_DATA_URL.test(url)) { + // Read ArrayBuffer from Data URL of JPEG images directly for better performance + if (REGEXP_DATA_URL_JPEG.test(url)) { + this.read(dataURLToArrayBuffer(url)); + } else { + // Only a JPEG image may contains Exif Orientation information, + // the rest types of Data URLs are not necessary to check orientation at all. + this.clone(); + } + return; + } + + // 1. Detect the mime type of the image by a XMLHttpRequest. + // 2. Load the image as ArrayBuffer for reading orientation if its a JPEG image. + var xhr = new XMLHttpRequest(); + var clone = this.clone.bind(this); + this.reloading = true; + this.xhr = xhr; + + // 1. Cross origin requests are only supported for protocol schemes: + // http, https, data, chrome, chrome-extension. + // 2. Access to XMLHttpRequest from a Data URL will be blocked by CORS policy + // in some browsers as IE11 and Safari. + xhr.onabort = clone; + xhr.onerror = clone; + xhr.ontimeout = clone; + xhr.onprogress = function () { + // Abort the request directly if it not a JPEG image for better performance + if (xhr.getResponseHeader('content-type') !== MIME_TYPE_JPEG) { + xhr.abort(); + } + }; + xhr.onload = function () { + _this.read(xhr.response); + }; + xhr.onloadend = function () { + _this.reloading = false; + _this.xhr = null; + }; + + // Bust cache when there is a "crossOrigin" property to avoid browser cache error + if (options.checkCrossOrigin && isCrossOriginURL(url) && element.crossOrigin) { + url = addTimestamp(url); + } + + // The third parameter is required for avoiding side-effect (#682) + xhr.open('GET', url, true); + xhr.responseType = 'arraybuffer'; + xhr.withCredentials = element.crossOrigin === 'use-credentials'; + xhr.send(); + } + }, { + key: "read", + value: function read(arrayBuffer) { + var options = this.options, + imageData = this.imageData; + + // Reset the orientation value to its default value 1 + // as some iOS browsers will render image with its orientation + var orientation = resetAndGetOrientation(arrayBuffer); + var rotate = 0; + var scaleX = 1; + var scaleY = 1; + if (orientation > 1) { + // Generate a new URL which has the default orientation value + this.url = arrayBufferToDataURL(arrayBuffer, MIME_TYPE_JPEG); + var _parseOrientation = parseOrientation(orientation); + rotate = _parseOrientation.rotate; + scaleX = _parseOrientation.scaleX; + scaleY = _parseOrientation.scaleY; + } + if (options.rotatable) { + imageData.rotate = rotate; + } + if (options.scalable) { + imageData.scaleX = scaleX; + imageData.scaleY = scaleY; + } + this.clone(); + } + }, { + key: "clone", + value: function clone() { + var element = this.element, + url = this.url; + var crossOrigin = element.crossOrigin; + var crossOriginUrl = url; + if (this.options.checkCrossOrigin && isCrossOriginURL(url)) { + if (!crossOrigin) { + crossOrigin = 'anonymous'; + } + + // Bust cache when there is not a "crossOrigin" property (#519) + crossOriginUrl = addTimestamp(url); + } + this.crossOrigin = crossOrigin; + this.crossOriginUrl = crossOriginUrl; + var image = document.createElement('img'); + if (crossOrigin) { + image.crossOrigin = crossOrigin; + } + image.src = crossOriginUrl || url; + image.alt = element.alt || 'The image to crop'; + this.image = image; + image.onload = this.start.bind(this); + image.onerror = this.stop.bind(this); + addClass(image, CLASS_HIDE); + element.parentNode.insertBefore(image, element.nextSibling); + } + }, { + key: "start", + value: function start() { + var _this2 = this; + var image = this.image; + image.onload = null; + image.onerror = null; + this.sizing = true; + + // Match all browsers that use WebKit as the layout engine in iOS devices, + // such as Safari for iOS, Chrome for iOS, and in-app browsers. + var isIOSWebKit = WINDOW.navigator && /(?:iPad|iPhone|iPod).*?AppleWebKit/i.test(WINDOW.navigator.userAgent); + var done = function done(naturalWidth, naturalHeight) { + assign(_this2.imageData, { + naturalWidth: naturalWidth, + naturalHeight: naturalHeight, + aspectRatio: naturalWidth / naturalHeight + }); + _this2.initialImageData = assign({}, _this2.imageData); + _this2.sizing = false; + _this2.sized = true; + _this2.build(); + }; + + // Most modern browsers (excepts iOS WebKit) + if (image.naturalWidth && !isIOSWebKit) { + done(image.naturalWidth, image.naturalHeight); + return; + } + var sizingImage = document.createElement('img'); + var body = document.body || document.documentElement; + this.sizingImage = sizingImage; + sizingImage.onload = function () { + done(sizingImage.width, sizingImage.height); + if (!isIOSWebKit) { + body.removeChild(sizingImage); + } + }; + sizingImage.src = image.src; + + // iOS WebKit will convert the image automatically + // with its orientation once append it into DOM (#279) + if (!isIOSWebKit) { + sizingImage.style.cssText = 'left:0;' + 'max-height:none!important;' + 'max-width:none!important;' + 'min-height:0!important;' + 'min-width:0!important;' + 'opacity:0;' + 'position:absolute;' + 'top:0;' + 'z-index:-1;'; + body.appendChild(sizingImage); + } + } + }, { + key: "stop", + value: function stop() { + var image = this.image; + image.onload = null; + image.onerror = null; + image.parentNode.removeChild(image); + this.image = null; + } + }, { + key: "build", + value: function build() { + if (!this.sized || this.ready) { + return; + } + var element = this.element, + options = this.options, + image = this.image; + + // Create cropper elements + var container = element.parentNode; + var template = document.createElement('div'); + template.innerHTML = TEMPLATE; + var cropper = template.querySelector(".".concat(NAMESPACE, "-container")); + var canvas = cropper.querySelector(".".concat(NAMESPACE, "-canvas")); + var dragBox = cropper.querySelector(".".concat(NAMESPACE, "-drag-box")); + var cropBox = cropper.querySelector(".".concat(NAMESPACE, "-crop-box")); + var face = cropBox.querySelector(".".concat(NAMESPACE, "-face")); + this.container = container; + this.cropper = cropper; + this.canvas = canvas; + this.dragBox = dragBox; + this.cropBox = cropBox; + this.viewBox = cropper.querySelector(".".concat(NAMESPACE, "-view-box")); + this.face = face; + canvas.appendChild(image); + + // Hide the original image + addClass(element, CLASS_HIDDEN); + + // Inserts the cropper after to the current image + container.insertBefore(cropper, element.nextSibling); + + // Show the hidden image + removeClass(image, CLASS_HIDE); + this.initPreview(); + this.bind(); + options.initialAspectRatio = Math.max(0, options.initialAspectRatio) || NaN; + options.aspectRatio = Math.max(0, options.aspectRatio) || NaN; + options.viewMode = Math.max(0, Math.min(3, Math.round(options.viewMode))) || 0; + addClass(cropBox, CLASS_HIDDEN); + if (!options.guides) { + addClass(cropBox.getElementsByClassName("".concat(NAMESPACE, "-dashed")), CLASS_HIDDEN); + } + if (!options.center) { + addClass(cropBox.getElementsByClassName("".concat(NAMESPACE, "-center")), CLASS_HIDDEN); + } + if (options.background) { + addClass(cropper, "".concat(NAMESPACE, "-bg")); + } + if (!options.highlight) { + addClass(face, CLASS_INVISIBLE); + } + if (options.cropBoxMovable) { + addClass(face, CLASS_MOVE); + setData(face, DATA_ACTION, ACTION_ALL); + } + if (!options.cropBoxResizable) { + addClass(cropBox.getElementsByClassName("".concat(NAMESPACE, "-line")), CLASS_HIDDEN); + addClass(cropBox.getElementsByClassName("".concat(NAMESPACE, "-point")), CLASS_HIDDEN); + } + this.render(); + this.ready = true; + this.setDragMode(options.dragMode); + if (options.autoCrop) { + this.crop(); + } + this.setData(options.data); + if (isFunction(options.ready)) { + addListener(element, EVENT_READY, options.ready, { + once: true + }); + } + dispatchEvent(element, EVENT_READY); + } + }, { + key: "unbuild", + value: function unbuild() { + if (!this.ready) { + return; + } + this.ready = false; + this.unbind(); + this.resetPreview(); + var parentNode = this.cropper.parentNode; + if (parentNode) { + parentNode.removeChild(this.cropper); + } + removeClass(this.element, CLASS_HIDDEN); + } + }, { + key: "uncreate", + value: function uncreate() { + if (this.ready) { + this.unbuild(); + this.ready = false; + this.cropped = false; + } else if (this.sizing) { + this.sizingImage.onload = null; + this.sizing = false; + this.sized = false; + } else if (this.reloading) { + this.xhr.onabort = null; + this.xhr.abort(); + } else if (this.image) { + this.stop(); + } + } + + /** + * Get the no conflict cropper class. + * @returns {Cropper} The cropper class. + */ + }], [{ + key: "noConflict", + value: function noConflict() { + window.Cropper = AnotherCropper; + return Cropper; + } + + /** + * Change the default options. + * @param {Object} options - The new default options. + */ + }, { + key: "setDefaults", + value: function setDefaults(options) { + assign(DEFAULTS, isPlainObject(options) && options); + } + }]); + return Cropper; + }(); + assign(Cropper.prototype, render, preview, events, handlers, change, methods); + + return Cropper; + +})); diff --git a/plugins/sceneCoverCropper/sceneCoverCropper.js b/plugins/sceneCoverCropper/sceneCoverCropper.js new file mode 100644 index 00000000..7d53c4ee --- /dev/null +++ b/plugins/sceneCoverCropper/sceneCoverCropper.js @@ -0,0 +1,145 @@ +// By ScruffyNerf +// Ported by feederbox826 + +(function () { + let cropping = false; + let cropper = null; + + try { + const img = document.createElement('img'); + new Cropper(img) + } catch (e) { + console.error("Cropper not loaded - please install 4. CropperJS from CommunityScripts") + } + try { + stash.getVersion() + } catch (e) { + console.error("Stash not loaded - please install 1. stashUserscriptLibrary from CommunityScripts") + } + + function setupCropper() { + const cropBtnContainerId = "crop-btn-container"; + if (document.getElementById(cropBtnContainerId)) return + const sceneId = window.location.pathname.replace('/scenes/', '').split('/')[0]; + const sceneImage = document.querySelector("img.scene-cover") + + var cropperModal = document.createElement("dialog"); + cropperModal.style.width = "90%"; + cropperModal.style.border = "none"; + cropperModal.classList.add('bg-dark'); + document.body.appendChild(cropperModal); + + var cropperContainer = document.createElement("div"); + cropperContainer.style.width = "100%"; + cropperContainer.style.height = "auto"; + cropperContainer.style.margin = "auto"; + cropperModal.appendChild(cropperContainer); + + var image = sceneImage.cloneNode(); + image.style.display = "block"; + image.style.maxWidth = "100%"; + cropperContainer.appendChild(image); + + var cropBtnContainer = document.createElement('div'); + cropBtnContainer.setAttribute("id", cropBtnContainerId); + cropBtnContainer.classList.add('d-flex','flex-row','justify-content-center','align-items-center'); + cropBtnContainer.style.gap = "10px"; + cropperModal.appendChild(cropBtnContainer); + + + sceneImage.parentElement.parentElement.style.flexFlow = 'column'; + + const cropInfo = document.createElement('p'); + cropInfo.style.all = "revert"; + cropInfo.classList.add('text-white'); + + const cropStart = document.createElement('button'); + cropStart.setAttribute("id", "crop-start"); + cropStart.classList.add('btn', 'btn-primary'); + cropStart.innerText = 'Crop Image'; + cropStart.addEventListener('click', evt => { + cropping = true; + cropStart.style.display = 'none'; + cropCancel.style.display = 'inline-block'; + + //const isVertical = image.naturalHeight > image.naturalWidth; + //const aspectRatio = isVertical ? 3/2 : NaN + const aspectRatio = NaN + + cropper = new Cropper(image, { + viewMode: 1, + initialAspectRatio: aspectRatio, + movable: false, + rotatable: false, + scalable: false, + zoomable: false, + zoomOnTouch: false, + zoomOnWheel: false, + ready() { + cropAccept.style.display = 'inline-block'; + }, + crop(e) { + cropInfo.innerText = `X: ${Math.round(e.detail.x)}, Y: ${Math.round(e.detail.y)}, Width: ${Math.round(e.detail.width)}px, Height: ${Math.round(e.detail.height)}px`; + } + }); + cropperModal.showModal(); + }); + sceneImage.parentElement.appendChild(cropStart); + + const cropAccept = document.createElement('button'); + cropAccept.setAttribute("id", "crop-accept"); + cropAccept.classList.add('btn', 'btn-success', 'mr-2'); + cropAccept.innerText = 'OK'; + cropAccept.addEventListener('click', async evt => { + cropping = false; + cropStart.style.display = 'inline-block'; + cropAccept.style.display = 'none'; + cropCancel.style.display = 'none'; + cropInfo.innerText = ''; + + const reqData = { + "operationName": "SceneUpdate", + "variables": { + "input": { + "cover_image": cropper.getCroppedCanvas().toDataURL(), + "id": sceneId + } + }, + "query": `mutation SceneUpdate($input: SceneUpdateInput!) { + sceneUpdate(input: $input) { + id + } + }` + } + await stash.callGQL(reqData); + reloadImg(image.src); + cropper.destroy(); + cropperModal.close("cropAccept"); + }); + cropBtnContainer.appendChild(cropAccept); + + const cropCancel = document.createElement('button'); + cropCancel.setAttribute("id", "crop-accept"); + cropCancel.classList.add('btn', 'btn-danger'); + cropCancel.innerText = 'Cancel'; + cropCancel.addEventListener('click', evt => { + cropping = false; + cropStart.style.display = 'inline-block'; + cropAccept.style.display = 'none'; + cropCancel.style.display = 'none'; + cropInfo.innerText = ''; + + cropper.destroy(); + cropperModal.close("cropCancel"); + }); + cropBtnContainer.appendChild(cropCancel); + cropAccept.style.display = 'none'; + cropCancel.style.display = 'none'; + + cropBtnContainer.appendChild(cropInfo); + } + + stash.addEventListener('page:scene', function () { + waitForElementId('scene-edit-details', setupCropper); + }); +})(); diff --git a/plugins/sceneCoverCropper/sceneCoverCropper.yml b/plugins/sceneCoverCropper/sceneCoverCropper.yml new file mode 100644 index 00000000..735cf4a3 --- /dev/null +++ b/plugins/sceneCoverCropper/sceneCoverCropper.yml @@ -0,0 +1,7 @@ +name: Scene Cover Cropper +description: Crop Scene Cover Images +version: 1.0 +ui: + css: + javascript: + - sceneCoverCropper.js \ No newline at end of file From 6fb95ada30198c7e65b501657952d2bee03c3442 Mon Sep 17 00:00:00 2001 From: alucicrazy <122958864+alucicrazy@users.noreply.github.com> Date: Sat, 4 Nov 2023 02:32:10 -0300 Subject: [PATCH 43/55] Add zip format to blocklist --- plugins/filenameParser/filenameParser.js | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/filenameParser/filenameParser.js b/plugins/filenameParser/filenameParser.js index 999f29b2..c6e94b50 100644 --- a/plugins/filenameParser/filenameParser.js +++ b/plugins/filenameParser/filenameParser.js @@ -162,6 +162,7 @@ function cleanFilename(name) { var blockList = [ 'mp4', 'mov', + 'zip', 'xxx', '4k', '4096x2160', From 952c99ec7bdd7b2e7144872f378849e1dc42c841 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 26 Oct 2023 14:02:57 +1100 Subject: [PATCH 44/55] Build stable and main indexes --- .github/workflows/deploy.yml | 48 +++++++++++++++++++++++++++++++ .gitignore | 1 + build_site.sh | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100755 build_site.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..1e5ed7ae --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,48 @@ +name: Deploy repository to Github Pages + +on: + push: + branches: [ main, stable ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-20.04 + steps: + - name: Checkout main + uses: actions/checkout@v2 + with: + path: main + ref: main + - run: | + cd main + ./build_site.sh ../_site/develop + - name: Checkout Stable + uses: actions/checkout@v2 + with: + path: stable + ref: stable + - run: | + cd stable + ../master/build_site.sh ../_site/stable + - uses: actions/upload-pages-artifact@v1 + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-20.04 + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v1 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..16182c5d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/_site \ No newline at end of file diff --git a/build_site.sh b/build_site.sh new file mode 100755 index 00000000..d849002f --- /dev/null +++ b/build_site.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# builds a repository of scrapers +# outputs to _site with the following structure: +# index.yml +# .zip +# Each zip file contains the scraper.yml file and any other files in the same directory + +outdir="$1" +if [ -z "$outdir" ]; then + outdir="_site" +fi + +rm -rf "$outdir" +mkdir -p "$outdir" + +buildPlugin() +{ + f=$1 + # get the scraper id from the directory + plugin_id=$(basename "$f") + + echo "Processing $plugin_id" + + # create a directory for the version + version=$(git log -n 1 --pretty=format:%h -- "$f"/*) + updated=$(git log -n 1 --date="format:%F %T %z" --pretty=format:%ad -- "$f"/*) + + # create the zip file + # copy other files + zipfile=$(realpath "$outdir/$plugin_id.zip") + + pushd "$f" > /dev/null + zip -r "$zipfile" . > /dev/null + popd > /dev/null + + name=$(grep "^name:" "$f"/*.yml | head -n 1 | cut -d' ' -f2- | sed -e 's/\r//' -e 's/^"\(.*\)"$/\1/') + description=$(grep "^description:" "$f"/*.yml | head -n 1 | cut -d' ' -f2- | sed -e 's/\r//' -e 's/^"\(.*\)"$/\1/') + ymlVersion=$(grep "^version:" "$f"/*.yml | head -n 1 | cut -d' ' -f2- | sed -e 's/\r//' -e 's/^"\(.*\)"$/\1/') + version="$ymlVersion-$version" + + # write to spec index + echo "- id: $plugin_id + name: $name + metadata: + description: $description + version: $version + date: $updated + path: $plugin_id.zip + sha256: $(sha256sum "$zipfile" | cut -d' ' -f1) +" >> "$outdir"/index.yml +} + +find ./plugins -mindepth 1 -maxdepth 1 -type d | while read file; do + buildPlugin "$file" +done From 7b04605560d15f21abeca2fa42568122d6e3e4ad Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 22 Nov 2023 14:48:16 +1100 Subject: [PATCH 45/55] Remove number from CropperJS --- plugins/{4. CropperJS => CropperJS}/CropperJS.yml | 0 plugins/{4. CropperJS => CropperJS}/cropper.css | 0 plugins/{4. CropperJS => CropperJS}/cropper.js | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename plugins/{4. CropperJS => CropperJS}/CropperJS.yml (100%) rename plugins/{4. CropperJS => CropperJS}/cropper.css (100%) rename plugins/{4. CropperJS => CropperJS}/cropper.js (100%) diff --git a/plugins/4. CropperJS/CropperJS.yml b/plugins/CropperJS/CropperJS.yml similarity index 100% rename from plugins/4. CropperJS/CropperJS.yml rename to plugins/CropperJS/CropperJS.yml diff --git a/plugins/4. CropperJS/cropper.css b/plugins/CropperJS/cropper.css similarity index 100% rename from plugins/4. CropperJS/cropper.css rename to plugins/CropperJS/cropper.css diff --git a/plugins/4. CropperJS/cropper.js b/plugins/CropperJS/cropper.js similarity index 100% rename from plugins/4. CropperJS/cropper.js rename to plugins/CropperJS/cropper.js From 2784391227e85a818afa30dfed2ea86304de25c2 Mon Sep 17 00:00:00 2001 From: scruffynerf Date: Wed, 22 Nov 2023 01:18:43 -0500 Subject: [PATCH 46/55] Fix deprecated configuration getting with StashAPI My bad, when I merged this into the repo on Oct 19th, I missed this deprecated stashapi call, and the stashapi change had taken effect on Oct 11th, so... bad timing. Luckily it's a one line fix. --- scripts/stash-watcher/watcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/stash-watcher/watcher.py b/scripts/stash-watcher/watcher.py index 623c7359..5eb8328f 100644 --- a/scripts/stash-watcher/watcher.py +++ b/scripts/stash-watcher/watcher.py @@ -223,7 +223,7 @@ def parseConfig(path): #If the extensions are in the config, use them. Otherwise pull them from stash. extensions = config.getlist('Config', 'Extensions') if not extensions: - stashConfig = stash.graphql_configuration() + stashConfig = stash.get_configuration() extensions = stashConfig['general']['videoExtensions'] + stashConfig['general']['imageExtensions'] + stashConfig['general']['galleryExtensions'] pollIntervalStr = config.get('Config', 'PollInterval') From a163eafe12519d0d3ad1e68b6ede86fdb4d58903 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 22 Nov 2023 18:00:28 +1100 Subject: [PATCH 47/55] Remove leading numbers --- .../stashBatchResultToggle.js | 0 .../stashBatchResultToggle.yml | 0 .../StashUserscriptLibrary.yml | 0 .../stashUserscriptLibrary.js | 0 plugins/{2. stats => stats}/stats.js | 0 plugins/{2. stats => stats}/stats.yml | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename plugins/{3. Stash Batch Result Toggle => StashBatchResultToggle}/stashBatchResultToggle.js (100%) rename plugins/{3. Stash Batch Result Toggle => StashBatchResultToggle}/stashBatchResultToggle.yml (100%) rename plugins/{1. stashUserscriptLibrary => stashUserscriptLibrary}/StashUserscriptLibrary.yml (100%) rename plugins/{1. stashUserscriptLibrary => stashUserscriptLibrary}/stashUserscriptLibrary.js (100%) rename plugins/{2. stats => stats}/stats.js (100%) rename plugins/{2. stats => stats}/stats.yml (100%) diff --git a/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js b/plugins/StashBatchResultToggle/stashBatchResultToggle.js similarity index 100% rename from plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.js rename to plugins/StashBatchResultToggle/stashBatchResultToggle.js diff --git a/plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.yml b/plugins/StashBatchResultToggle/stashBatchResultToggle.yml similarity index 100% rename from plugins/3. Stash Batch Result Toggle/stashBatchResultToggle.yml rename to plugins/StashBatchResultToggle/stashBatchResultToggle.yml diff --git a/plugins/1. stashUserscriptLibrary/StashUserscriptLibrary.yml b/plugins/stashUserscriptLibrary/StashUserscriptLibrary.yml similarity index 100% rename from plugins/1. stashUserscriptLibrary/StashUserscriptLibrary.yml rename to plugins/stashUserscriptLibrary/StashUserscriptLibrary.yml diff --git a/plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js b/plugins/stashUserscriptLibrary/stashUserscriptLibrary.js similarity index 100% rename from plugins/1. stashUserscriptLibrary/stashUserscriptLibrary.js rename to plugins/stashUserscriptLibrary/stashUserscriptLibrary.js diff --git a/plugins/2. stats/stats.js b/plugins/stats/stats.js similarity index 100% rename from plugins/2. stats/stats.js rename to plugins/stats/stats.js diff --git a/plugins/2. stats/stats.yml b/plugins/stats/stats.yml similarity index 100% rename from plugins/2. stats/stats.yml rename to plugins/stats/stats.yml From 7c47b63d5435e8d1c23444fedc3d382b91472281 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 22 Nov 2023 18:06:12 +1100 Subject: [PATCH 48/55] Add dependencies --- plugins/StashBatchResultToggle/stashBatchResultToggle.yml | 3 +++ plugins/sceneCoverCropper/sceneCoverCropper.yml | 3 +++ plugins/stats/stats.yml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/plugins/StashBatchResultToggle/stashBatchResultToggle.yml b/plugins/StashBatchResultToggle/stashBatchResultToggle.yml index 45c47492..a07558f7 100644 --- a/plugins/StashBatchResultToggle/stashBatchResultToggle.yml +++ b/plugins/StashBatchResultToggle/stashBatchResultToggle.yml @@ -1,6 +1,9 @@ name: Stash Batch Result Toggle. +# requires: StashUserscriptLibrary description: In Scene Tagger, adds button to toggle all stashdb scene match result fields. Saves clicks when you only want to save a few metadata fields. Instead of turning off every field, you batch toggle them off, then toggle on the ones you want version: 1.0 ui: + requires: + - StashUserscriptLibrary javascript: - stashBatchResultToggle.js diff --git a/plugins/sceneCoverCropper/sceneCoverCropper.yml b/plugins/sceneCoverCropper/sceneCoverCropper.yml index 735cf4a3..53f71118 100644 --- a/plugins/sceneCoverCropper/sceneCoverCropper.yml +++ b/plugins/sceneCoverCropper/sceneCoverCropper.yml @@ -1,7 +1,10 @@ name: Scene Cover Cropper +# requires: CropperJS description: Crop Scene Cover Images version: 1.0 ui: + requires: + - CropperJS css: javascript: - sceneCoverCropper.js \ No newline at end of file diff --git a/plugins/stats/stats.yml b/plugins/stats/stats.yml index f86e6a14..255ca988 100644 --- a/plugins/stats/stats.yml +++ b/plugins/stats/stats.yml @@ -1,6 +1,9 @@ name: Extended Stats +# requires: StashUserscriptLibrary description: Adds new stats to the stats page version: 1.0 ui: + requires: + - StashUserscriptLibrary javascript: - stats.js From 182b28d164592e6f42d5035d41e9f75211a6cb4f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 22 Nov 2023 18:06:46 +1100 Subject: [PATCH 49/55] Handle dependencies --- build_site.sh | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/build_site.sh b/build_site.sh index d849002f..3cf6d2a8 100755 --- a/build_site.sh +++ b/build_site.sh @@ -18,26 +18,28 @@ buildPlugin() { f=$1 # get the scraper id from the directory - plugin_id=$(basename "$f") + dir=$(dirname "$f") + plugin_id=$(basename "$f" .yml) echo "Processing $plugin_id" # create a directory for the version - version=$(git log -n 1 --pretty=format:%h -- "$f"/*) - updated=$(git log -n 1 --date="format:%F %T %z" --pretty=format:%ad -- "$f"/*) + version=$(git log -n 1 --pretty=format:%h -- "$dir"/*) + updated=$(git log -n 1 --date="format:%F %T %z" --pretty=format:%ad -- "$dir"/*) # create the zip file # copy other files zipfile=$(realpath "$outdir/$plugin_id.zip") - pushd "$f" > /dev/null + pushd "$dir" > /dev/null zip -r "$zipfile" . > /dev/null popd > /dev/null - name=$(grep "^name:" "$f"/*.yml | head -n 1 | cut -d' ' -f2- | sed -e 's/\r//' -e 's/^"\(.*\)"$/\1/') - description=$(grep "^description:" "$f"/*.yml | head -n 1 | cut -d' ' -f2- | sed -e 's/\r//' -e 's/^"\(.*\)"$/\1/') - ymlVersion=$(grep "^version:" "$f"/*.yml | head -n 1 | cut -d' ' -f2- | sed -e 's/\r//' -e 's/^"\(.*\)"$/\1/') + name=$(grep "^name:" "$f" | head -n 1 | cut -d' ' -f2- | sed -e 's/\r//' -e 's/^"\(.*\)"$/\1/') + description=$(grep "^description:" "$f" | head -n 1 | cut -d' ' -f2- | sed -e 's/\r//' -e 's/^"\(.*\)"$/\1/') + ymlVersion=$(grep "^version:" "$f" | head -n 1 | cut -d' ' -f2- | sed -e 's/\r//' -e 's/^"\(.*\)"$/\1/') version="$ymlVersion-$version" + dep=$(grep "^# requires:" "$f" | cut -c 12- | sed -e 's/\r//') # write to spec index echo "- id: $plugin_id @@ -47,10 +49,19 @@ buildPlugin() version: $version date: $updated path: $plugin_id.zip - sha256: $(sha256sum "$zipfile" | cut -d' ' -f1) -" >> "$outdir"/index.yml + sha256: $(sha256sum "$zipfile" | cut -d' ' -f1)" >> "$outdir"/index.yml + + # handle dependencies + if [ ! -z "$dep" ]; then + echo " requires:" >> "$outdir"/index.yml + for d in ${dep//,/ }; do + echo " - $d" >> "$outdir"/index.yml + done + fi + + echo "" >> "$outdir"/index.yml } -find ./plugins -mindepth 1 -maxdepth 1 -type d | while read file; do +find ./plugins -mindepth 1 -name *.yml | while read file; do buildPlugin "$file" done From 558b3f1712fb6e8735592edf2632e255719df56e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 27 Nov 2023 13:41:52 +1100 Subject: [PATCH 50/55] Use UTC timestamps without timezone --- build_site.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build_site.sh b/build_site.sh index 3cf6d2a8..1c9a59ad 100755 --- a/build_site.sh +++ b/build_site.sh @@ -25,7 +25,7 @@ buildPlugin() # create a directory for the version version=$(git log -n 1 --pretty=format:%h -- "$dir"/*) - updated=$(git log -n 1 --date="format:%F %T %z" --pretty=format:%ad -- "$dir"/*) + updated=$(TZ=UTC0 git log -n 1 --date="format-local:%F %T" --pretty=format:%ad -- "$dir"/*) # create the zip file # copy other files From 92b869284d046626de37bc03b9348f1384e2435b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 28 Nov 2023 09:50:06 +1100 Subject: [PATCH 51/55] Update deploy action --- .github/workflows/deploy.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1e5ed7ae..e6003e53 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,34 +15,37 @@ permissions: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout main uses: actions/checkout@v2 with: path: main ref: main + fetch-depth: '0' - run: | cd main ./build_site.sh ../_site/develop - - name: Checkout Stable - uses: actions/checkout@v2 - with: - path: stable - ref: stable - - run: | - cd stable - ../master/build_site.sh ../_site/stable - - uses: actions/upload-pages-artifact@v1 + # uncomment this once we have a stable branch + # - name: Checkout Stable + # uses: actions/checkout@v2 + # with: + # path: stable + # ref: stable + # fetch-depth: '0' + # - run: | + # cd stable + # ../master/build_site.sh ../_site/stable + # - uses: actions/upload-pages-artifact@v2 deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: build steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v1 + uses: actions/deploy-pages@v2 From 79b544efe7aa6adf52f2e555434fc38eb1b8665f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 28 Nov 2023 09:56:24 +1100 Subject: [PATCH 52/55] Fix incorrectly commented line --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e6003e53..ba8c2b04 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -36,7 +36,7 @@ jobs: # - run: | # cd stable # ../master/build_site.sh ../_site/stable - # - uses: actions/upload-pages-artifact@v2 + - uses: actions/upload-pages-artifact@v2 deploy: environment: From 27b74b8d33bae211843563e33da737b6eb46eb2d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 12 Dec 2023 12:00:04 +1100 Subject: [PATCH 53/55] Build stable branch --- .github/workflows/deploy.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ba8c2b04..2450e61f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,15 +27,15 @@ jobs: cd main ./build_site.sh ../_site/develop # uncomment this once we have a stable branch - # - name: Checkout Stable - # uses: actions/checkout@v2 - # with: - # path: stable - # ref: stable - # fetch-depth: '0' - # - run: | - # cd stable - # ../master/build_site.sh ../_site/stable + - name: Checkout Stable + uses: actions/checkout@v2 + with: + path: stable + ref: stable + fetch-depth: '0' + - run: | + cd stable + ../main/build_site.sh ../_site/stable - uses: actions/upload-pages-artifact@v2 deploy: From 0e8f91145876276e8a0b05c8596a525d89a4f453 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 20 Dec 2023 14:07:15 +1100 Subject: [PATCH 54/55] Use #pkgignore to omit from build script --- build_site.sh | 5 +++++ plugins/comicInfoExtractor/config.yml | 1 + 2 files changed, 6 insertions(+) diff --git a/build_site.sh b/build_site.sh index 1c9a59ad..463ee690 100755 --- a/build_site.sh +++ b/build_site.sh @@ -17,6 +17,11 @@ mkdir -p "$outdir" buildPlugin() { f=$1 + + if grep -q "^#pkgignore" "$f"; then + return + fi + # get the scraper id from the directory dir=$(dirname "$f") plugin_id=$(basename "$f" .yml) diff --git a/plugins/comicInfoExtractor/config.yml b/plugins/comicInfoExtractor/config.yml index 51c7d1f5..235e8524 100644 --- a/plugins/comicInfoExtractor/config.yml +++ b/plugins/comicInfoExtractor/config.yml @@ -1,3 +1,4 @@ +#pkgignore #ImportList is a dictionary #that matches an xml Attribute from ComicInfo.xml to the according value in stash (using the graphql naming) #Fields that refer to different types of media are resolved by name and created if necessary (tags, studio, performers) From adc642cc7bf147375783359db447b77106102dd9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 27 Dec 2023 17:14:29 +1100 Subject: [PATCH 55/55] Make main stable, develop a separate ref --- .github/workflows/deploy.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2450e61f..74b7f1e6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,17 +25,17 @@ jobs: fetch-depth: '0' - run: | cd main - ./build_site.sh ../_site/develop - # uncomment this once we have a stable branch - - name: Checkout Stable + ./build_site.sh ../_site/stable + - name: Checkout dev uses: actions/checkout@v2 with: - path: stable - ref: stable + path: dev + # change this ref to whatever dev branch/tag we need when necessary + ref: main fetch-depth: '0' - run: | - cd stable - ../main/build_site.sh ../_site/stable + cd dev + ../main/build_site.sh ../_site/develop - uses: actions/upload-pages-artifact@v2 deploy: