IDK
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds "AniList Companion.app" by copying the already-downloaded
|
||||
# Electron.app binary and rebranding it with our app code + a custom
|
||||
# Info.plist - avoids re-downloading Electron via electron-builder (disk
|
||||
# space is tight), same technique electron-packager uses internally.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ELECTRON_APP="$DIR/node_modules/electron/dist/Electron.app"
|
||||
OUT_APP="/Applications/AniList Companion.app"
|
||||
|
||||
if [ ! -d "$ELECTRON_APP" ]; then
|
||||
echo "Electron.app not found at $ELECTRON_APP - run 'npm install' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$OUT_APP"
|
||||
cp -R "$ELECTRON_APP" "$OUT_APP"
|
||||
|
||||
# Rename the binary itself so it shows up correctly in Activity Monitor /
|
||||
# Dock, and embed our app source into Contents/Resources/app.
|
||||
mv "$OUT_APP/Contents/MacOS/Electron" "$OUT_APP/Contents/MacOS/AniList Companion"
|
||||
|
||||
RESOURCES="$OUT_APP/Contents/Resources"
|
||||
mkdir -p "$RESOURCES/app"
|
||||
cp -R "$DIR/src" "$RESOURCES/app/src"
|
||||
cp "$DIR/package.json" "$RESOURCES/app/package.json"
|
||||
|
||||
# Custom Info.plist: proper app name, bundle id, icon reference.
|
||||
cat > "$OUT_APP/Contents/Info.plist" << 'PLIST'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>AniList Companion</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>AniList Companion</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.anilist-tracker.companion</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>AniList Companion</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>electron.icns</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
# Re-sign ad-hoc so macOS Gatekeeper accepts the renamed/modified bundle.
|
||||
codesign --force --deep --sign - "$OUT_APP" 2>&1 | tail -5
|
||||
|
||||
echo "Built: $OUT_APP"
|
||||
Generated
+889
@@ -0,0 +1,889 @@
|
||||
{
|
||||
"name": "anilist-app",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "anilist-app",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^38.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"got": "^11.8.5",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^6.2.0",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"global-agent": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defer-to-connect": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cacheable-request": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-cache-semantics": "*",
|
||||
"@types/keyv": "^3.1.4",
|
||||
"@types/node": "*",
|
||||
"@types/responselike": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/keyv": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
|
||||
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.5.tgz",
|
||||
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/responselike": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
||||
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-lookup": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
|
||||
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-request": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
|
||||
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clone-response": "^1.0.2",
|
||||
"get-stream": "^5.1.0",
|
||||
"http-cache-semantics": "^4.0.0",
|
||||
"keyv": "^4.0.0",
|
||||
"lowercase-keys": "^2.0.0",
|
||||
"normalize-url": "^6.0.1",
|
||||
"responselike": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clone-response": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response/node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/defer-to-connect": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "38.8.6",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-38.8.6.tgz",
|
||||
"integrity": "sha512-lyBhcVi9QYAZL6FO6r5twAWAjWnYomo3iVDvrb5SJZlq928BGemHOKG0tPIq41NOLaCu9f3XdEEjMkjQPjprRg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20.55"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "22.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz",
|
||||
"integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-error": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"extract-zip": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
||||
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"es6-error": "^4.1.1",
|
||||
"matcher": "^3.0.0",
|
||||
"roarr": "^2.15.3",
|
||||
"semver": "^7.3.2",
|
||||
"serialize-error": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"define-properties": "^1.2.1",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/got": {
|
||||
"version": "11.8.6",
|
||||
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
|
||||
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/is": "^4.0.0",
|
||||
"@szmarczak/http-timer": "^4.0.5",
|
||||
"@types/cacheable-request": "^6.0.1",
|
||||
"@types/responselike": "^1.0.0",
|
||||
"cacheable-lookup": "^5.0.3",
|
||||
"cacheable-request": "^7.0.2",
|
||||
"decompress-response": "^6.0.0",
|
||||
"http2-wrapper": "^1.0.0-beta.5.2",
|
||||
"lowercase-keys": "^2.0.0",
|
||||
"p-cancelable": "^2.0.0",
|
||||
"responselike": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/got?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/http2-wrapper": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
||||
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quick-lru": "^5.1.1",
|
||||
"resolve-alpn": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
||||
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-url": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-alpn": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/responselike": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
|
||||
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lowercase-keys": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/roarr": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"detect-node": "^2.0.4",
|
||||
"globalthis": "^1.0.1",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"semver-compare": "^1.0.0",
|
||||
"sprintf-js": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/semver-compare": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
||||
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
|
||||
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"type-fest": "^0.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "anilist-app",
|
||||
"version": "1.0.0",
|
||||
"description": "Standalone desktop app for tracking anime on AniList, with a built-in browser running the real MAL-Sync extension.",
|
||||
"main": "src/main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"build:mac": "electron-builder --mac"
|
||||
},
|
||||
"author": "linus",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^38.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("dashboardApi", {
|
||||
fetchWatching: () => ipcRenderer.invoke("fetch-watching"),
|
||||
searchAnime: (query) => ipcRenderer.invoke("search-anime", query),
|
||||
addAnime: (mediaId, status) => ipcRenderer.invoke("add-anime", mediaId, status),
|
||||
updateAnime: (mediaId, fields) => ipcRenderer.invoke("update-anime", mediaId, fields),
|
||||
watchNow: (url, trackable) => ipcRenderer.invoke("watch-now", { url, trackable }),
|
||||
fetchMediaDetail: (mediaId) => ipcRenderer.invoke("fetch-media-detail", mediaId),
|
||||
onRefresh: (cb) => ipcRenderer.on("refresh", cb)
|
||||
});
|
||||
@@ -0,0 +1,924 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0b12;
|
||||
--surface: #17171f;
|
||||
--surface-2: #1e1e28;
|
||||
--surface-3: #272734;
|
||||
--border: #2a2a38;
|
||||
--accent: #7c5cff;
|
||||
--accent-2: #b48cff;
|
||||
--accent-hover: #8f72ff;
|
||||
--text: #f3f3f8;
|
||||
--text-dim: #9c9cb0;
|
||||
--text-faint: #62627a;
|
||||
--green: #35d488;
|
||||
--orange: #ff8a3d;
|
||||
--radius: 16px;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
|
||||
background:
|
||||
radial-gradient(1100px 480px at 15% -10%, rgba(124,92,255,0.16), transparent 60%),
|
||||
radial-gradient(900px 420px at 100% 0%, rgba(255,138,61,0.08), transparent 55%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
padding: 32px 36px 70px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 5px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #34344a; }
|
||||
|
||||
header {
|
||||
margin-bottom: 26px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
.header-left h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.4px;
|
||||
background: linear-gradient(90deg, #fff, #c9bbff);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.user-line {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.user-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; background: var(--green);
|
||||
box-shadow: 0 0 8px var(--green);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.stat-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.stat-pill {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
text-align: center;
|
||||
min-width: 78px;
|
||||
}
|
||||
.stat-pill .num { font-size: 18px; font-weight: 800; color: #fff; line-height: 1.1; }
|
||||
.stat-pill .lbl { font-size: 10px; color: var(--text-faint); text-transform: uppercase; letter-spacing: .5px; margin-top: 2px; }
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 6px 0 6px;
|
||||
max-width: 680px;
|
||||
}
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 13px 16px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.search-bar input::placeholder { color: var(--text-faint); }
|
||||
.search-bar input:focus { outline: none; border-color: var(--accent); background: var(--surface-3); }
|
||||
.search-bar button {
|
||||
background: linear-gradient(135deg, var(--accent), #9b7bff);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 13px 22px;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform .1s, filter .15s;
|
||||
box-shadow: 0 4px 14px rgba(124,92,255,0.35);
|
||||
}
|
||||
.search-bar button:hover { filter: brightness(1.08); }
|
||||
.search-bar button:active { transform: scale(0.97); }
|
||||
|
||||
#search-status { font-size: 12px; color: var(--text-faint); min-height: 16px; margin-bottom: 10px; }
|
||||
#search-results { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; max-width: 680px; }
|
||||
.result-card {
|
||||
display: flex; gap: 12px; background: var(--surface-2); border-radius: 12px;
|
||||
padding: 10px; align-items: center; border: 1px solid var(--border);
|
||||
}
|
||||
.result-card img { width: 40px; height: 56px; border-radius: 7px; object-fit: cover; }
|
||||
.result-card .title { font-size: 13px; font-weight: 700; }
|
||||
.result-card .meta { font-size: 11px; color: var(--text-faint); margin-top: 2px; }
|
||||
.add-btn {
|
||||
margin-left: auto; background: var(--green); border: none; border-radius: 9px;
|
||||
padding: 7px 14px; color: #05230f; font-size: 12px; font-weight: 800; cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.add-btn:hover { filter: brightness(1.1); }
|
||||
.add-btn:disabled { background: var(--surface-3); color: var(--text-faint); cursor: default; }
|
||||
|
||||
.search-view-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-add-btn {
|
||||
background: var(--green);
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
padding: 9px 14px;
|
||||
color: #05230f;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
margin-top: auto;
|
||||
}
|
||||
.card-add-btn:hover { filter: brightness(1.1); }
|
||||
.card-add-btn:disabled { background: var(--surface-3); color: var(--text-faint); cursor: default; }
|
||||
.card-meta-line { font-size: 11px; color: var(--text-faint); font-weight: 600; }
|
||||
|
||||
h2.section-title {
|
||||
font-size: 12.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-faint);
|
||||
margin: 30px 0 16px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color .12s;
|
||||
}
|
||||
h2.section-title:hover { color: var(--text-dim); }
|
||||
h2.section-title .chevron {
|
||||
display: inline-block;
|
||||
transition: transform .15s;
|
||||
font-size: 10px;
|
||||
color: var(--accent-2);
|
||||
}
|
||||
h2.section-title.collapsed .chevron { transform: rotate(-90deg); }
|
||||
h2.section-title .count {
|
||||
background: var(--surface-3);
|
||||
color: var(--text-dim);
|
||||
padding: 2px 9px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.grid.collapsed { display: none; }
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin: 18px 0 4px;
|
||||
max-width: 920px;
|
||||
}
|
||||
.filter-bar input[type="text"] {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 9px 13px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
min-width: 210px;
|
||||
}
|
||||
.filter-bar input[type="text"]::placeholder { color: var(--text-faint); }
|
||||
.filter-bar input[type="text"]:focus { outline: none; border-color: var(--accent); }
|
||||
.filter-chip {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 20px;
|
||||
padding: 7px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all .12s;
|
||||
}
|
||||
.filter-chip:hover { border-color: #4a4a60; color: #ddd; }
|
||||
.filter-chip.active {
|
||||
background: linear-gradient(135deg, var(--accent), #9b7bff);
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
box-shadow: 0 3px 10px rgba(124,92,255,0.35);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
transition: border-color .18s, transform .15s, box-shadow .18s;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: rgba(124,92,255,0.4);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,0.35), 0 0 0 1px rgba(124,92,255,0.08);
|
||||
}
|
||||
|
||||
.card-cover {
|
||||
width: 92px;
|
||||
flex-shrink: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}
|
||||
.card-cover::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent 60%, rgba(23,23,31,0.5));
|
||||
}
|
||||
.status-pill {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 7px;
|
||||
background: rgba(10,10,16,0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
padding: 13px 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.28;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 8px; }
|
||||
.ep-control {
|
||||
display: flex; align-items: center; gap: 2px; background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 2px;
|
||||
}
|
||||
.ep-control button {
|
||||
background: var(--surface-3); border: none; color: var(--text); width: 22px; height: 22px;
|
||||
border-radius: 6px; cursor: pointer; font-size: 13px; line-height: 1;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background .12s;
|
||||
}
|
||||
.ep-control button:hover { background: #3a3a52; }
|
||||
.ep-control .ep-value { font-size: 12px; min-width: 20px; text-align: center; font-weight: 700; }
|
||||
.ep-total { font-size: 11px; color: var(--text-faint); font-weight: 600; }
|
||||
|
||||
.bar { height: 4px; background: var(--surface-3); border-radius: 3px; overflow: hidden; }
|
||||
.bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); border-radius: 3px; transition: width .2s; }
|
||||
|
||||
.meta-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* Custom select shell: hides native chrome, adds our own arrow/border */
|
||||
.select-wrap { position: relative; display: inline-flex; }
|
||||
.select-wrap::after {
|
||||
content: "";
|
||||
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
||||
width: 6px; height: 6px;
|
||||
border-right: 1.5px solid var(--text-faint);
|
||||
border-bottom: 1.5px solid var(--text-faint);
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
select.status-select, select.score-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 5px 22px 5px 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: border-color .12s, background .12s;
|
||||
}
|
||||
select.status-select:hover, select.score-select:hover { border-color: #444460; background: var(--surface-3); }
|
||||
select.status-select:focus, select.score-select:focus { outline: none; border-color: var(--accent); }
|
||||
.save-status { font-size: 11px; color: var(--green); min-width: 12px; font-weight: 700; }
|
||||
|
||||
.stream-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
|
||||
.stream-btn {
|
||||
border: none; border-radius: 8px; padding: 6px 12px; font-size: 11px; font-weight: 800;
|
||||
cursor: pointer; color: #fff; display: flex; align-items: center; gap: 5px;
|
||||
transition: filter .15s, transform .1s, box-shadow .15s;
|
||||
}
|
||||
.stream-btn:hover { filter: brightness(1.12); box-shadow: 0 3px 10px rgba(0,0,0,0.3); }
|
||||
.stream-btn:active { transform: scale(0.96); }
|
||||
.stream-btn .dot { width: 5px; height: 5px; border-radius: 50%; background: #fff; opacity: 0.85; }
|
||||
.no-stream { font-size: 11px; color: var(--text-faint); font-style: italic; }
|
||||
|
||||
.empty {
|
||||
color: var(--text-faint);
|
||||
padding: 50px 0;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-cover, .card-title { cursor: pointer; }
|
||||
|
||||
/* Detail modal */
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(5,5,10,0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.modal-backdrop.open { opacity: 1; pointer-events: all; }
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
width: min(640px, 90vw);
|
||||
max-height: 84vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
|
||||
transform: translateY(10px) scale(0.98);
|
||||
transition: transform .18s;
|
||||
}
|
||||
.modal-backdrop.open .modal { transform: translateY(0) scale(1); }
|
||||
.modal-banner {
|
||||
height: 160px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
background-color: var(--surface-2);
|
||||
}
|
||||
.modal-banner::after {
|
||||
content: "";
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.1), var(--surface) 96%);
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute; top: 14px; right: 14px;
|
||||
background: rgba(10,10,16,0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-close:hover { background: rgba(10,10,16,0.85); }
|
||||
.modal-body { padding: 0 28px 28px; margin-top: -64px; position: relative; }
|
||||
.modal-cover {
|
||||
width: 110px; height: 156px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.5);
|
||||
border: 3px solid var(--surface);
|
||||
}
|
||||
.modal-title-row { display: flex; gap: 18px; align-items: flex-end; }
|
||||
.modal-title { font-size: 20px; font-weight: 800; letter-spacing: -0.3px; margin-bottom: 6px; }
|
||||
.modal-native { font-size: 12px; color: var(--text-faint); }
|
||||
.modal-meta-chips { display: flex; gap: 6px; flex-wrap: wrap; margin: 16px 0; }
|
||||
.meta-chip {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 5px 11px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.meta-chip.score { color: var(--green); }
|
||||
.modal-genres { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.genre-tag {
|
||||
background: rgba(124,92,255,0.14);
|
||||
color: var(--accent-2);
|
||||
border-radius: 20px;
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.modal-desc {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--text-dim);
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-loading { padding: 60px 28px; text-align: center; color: var(--text-faint); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1>🎬 Meine AniList-Aktivität</h1>
|
||||
<div class="user-line"><span class="user-dot"></span><span id="user">Lade...</span></div>
|
||||
</div>
|
||||
<div class="stat-row" id="stat-row"></div>
|
||||
</header>
|
||||
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="Anime suchen und zur Liste hinzufügen..." />
|
||||
<button id="search-btn">Suchen</button>
|
||||
</div>
|
||||
<div id="search-status"></div>
|
||||
|
||||
<div id="search-view" style="display:none">
|
||||
<div class="search-view-header">
|
||||
<h2 class="section-title" style="margin-top:0">Suchergebnisse</h2>
|
||||
<button id="back-to-list-btn" class="filter-chip">← Zurück zur Liste</button>
|
||||
</div>
|
||||
<div class="grid" id="search-grid"></div>
|
||||
</div>
|
||||
|
||||
<div id="library-view">
|
||||
<div class="filter-bar">
|
||||
<input type="text" id="filter-text" placeholder="Meine Liste filtern..." />
|
||||
<button class="filter-chip active" data-status="ALL">Alle</button>
|
||||
<button class="filter-chip" data-status="CURRENT">Am Schauen</button>
|
||||
<button class="filter-chip" data-status="REPEATING">Wiederholung</button>
|
||||
<button class="filter-chip" data-status="PAUSED">Pausiert</button>
|
||||
<button class="filter-chip" data-status="PLANNING">Geplant</button>
|
||||
<button class="filter-chip" data-status="COMPLETED">Abgeschlossen</button>
|
||||
<button class="filter-chip" data-status="DROPPED">Abgebrochen</button>
|
||||
</div>
|
||||
|
||||
<div id="list"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop" id="modal-backdrop">
|
||||
<div class="modal" id="modal-content"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const STATUS_OPTIONS = [
|
||||
["CURRENT", "Am Schauen"],
|
||||
["COMPLETED", "Abgeschlossen"],
|
||||
["PAUSED", "Pausiert"],
|
||||
["DROPPED", "Abgebrochen"],
|
||||
["PLANNING", "Geplant"],
|
||||
["REPEATING", "Wiederholung"]
|
||||
];
|
||||
|
||||
function animeTitle(media) {
|
||||
return media.title.english || media.title.romaji;
|
||||
}
|
||||
|
||||
function scoreOptionsHtml(scoreFormat, currentScore) {
|
||||
let max, step = 1;
|
||||
switch (scoreFormat) {
|
||||
case "POINT_100": max = 100; step = 5; break;
|
||||
case "POINT_10_DECIMAL": max = 10; step = 1; break;
|
||||
case "POINT_10": max = 10; break;
|
||||
case "POINT_5": max = 5; break;
|
||||
case "POINT_3": max = 3; break;
|
||||
default: max = 10;
|
||||
}
|
||||
const values = [];
|
||||
for (let v = 0; v <= max; v += step) values.push(v);
|
||||
return values
|
||||
.map((v) => `<option value="${v}" ${currentScore === v ? "selected" : ""}>${v === 0 ? "★ -" : "★ " + v}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const q = document.getElementById("search-input").value.trim();
|
||||
const statusEl = document.getElementById("search-status");
|
||||
const searchViewEl = document.getElementById("search-view");
|
||||
const libraryViewEl = document.getElementById("library-view");
|
||||
const gridEl = document.getElementById("search-grid");
|
||||
if (!q) {
|
||||
gridEl.innerHTML = "";
|
||||
statusEl.textContent = "";
|
||||
searchViewEl.style.display = "none";
|
||||
libraryViewEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = "Suche...";
|
||||
try {
|
||||
const results = await window.dashboardApi.searchAnime(q);
|
||||
statusEl.textContent = results.length + " Ergebnis(se)";
|
||||
libraryViewEl.style.display = "none";
|
||||
searchViewEl.style.display = "";
|
||||
gridEl.innerHTML = results.map(searchCardHtml).join("");
|
||||
gridEl.querySelectorAll(".card-add-btn:not(:disabled)").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "...";
|
||||
try {
|
||||
await window.dashboardApi.addAnime(parseInt(btn.dataset.id, 10), "PLANNING");
|
||||
btn.textContent = "✓ Hinzugefügt";
|
||||
render();
|
||||
} catch (e) {
|
||||
btn.textContent = "Fehler";
|
||||
btn.title = e.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
statusEl.textContent = "Fehler: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Same card layout as the library grid (cover, title), but with just
|
||||
// an add button in place of progress/status/score/stream controls -
|
||||
// this view is only ever for adding a new show, not managing one.
|
||||
function searchCardHtml(m) {
|
||||
const title = animeTitle(m);
|
||||
const already = m.mediaListEntry;
|
||||
const cover = m.coverImage.medium;
|
||||
return `
|
||||
<div class="card">
|
||||
<div class="card-cover" style="background-image:url('${cover}')"></div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">${title}</div>
|
||||
<div class="card-meta-line">${m.episodes || "?"} Episoden · ${m.status}</div>
|
||||
<button class="card-add-btn" data-id="${m.id}" ${already ? "disabled" : ""}>
|
||||
${already ? "Auf Liste" : "+ Hinzufügen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById("search-btn").addEventListener("click", doSearch);
|
||||
document.getElementById("search-input").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") doSearch();
|
||||
});
|
||||
document.getElementById("back-to-list-btn").addEventListener("click", () => {
|
||||
document.getElementById("search-input").value = "";
|
||||
doSearch();
|
||||
});
|
||||
|
||||
|
||||
function flashSaved(el) {
|
||||
el.textContent = "✓";
|
||||
setTimeout(() => { el.textContent = ""; }, 1400);
|
||||
}
|
||||
|
||||
// Only real, AniList-confirmed availability is ever shown - no site
|
||||
// picker, no "maybe it's here" guessing. If a show has zero known
|
||||
// streaming links, we say so plainly instead of offering a dead end.
|
||||
function streamButtonsHtml(streamingLinks) {
|
||||
if (!streamingLinks || streamingLinks.length === 0) {
|
||||
return '<span class="no-stream">Keine bekannte Streaming-Quelle</span>';
|
||||
}
|
||||
return streamingLinks.map((l, i) => {
|
||||
const bg = l.color || "#7c5cff";
|
||||
return `<button class="stream-btn" style="background:${bg}" data-idx="${i}">
|
||||
<span class="dot"></span>${l.site}
|
||||
</button>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function cardHtml(e, scoreFormat) {
|
||||
const title = animeTitle(e.media);
|
||||
const total = e.media.episodes || null;
|
||||
const pct = total ? Math.round((e.progress / total) * 100) : 0;
|
||||
const scoreOptions = scoreOptionsHtml(scoreFormat, e.score);
|
||||
const statusOptions = STATUS_OPTIONS
|
||||
.map(([val, label]) => `<option value="${val}" ${e.status === val ? "selected" : ""}>${label}</option>`)
|
||||
.join("");
|
||||
const statusLabel = STATUS_OPTIONS.find(([v]) => v === e.status)?.[1] || e.status;
|
||||
const cover = e.media.coverImage.large || e.media.coverImage.medium;
|
||||
|
||||
return `
|
||||
<div class="card" data-media-id="${e.media.id}">
|
||||
<div class="card-cover open-detail" data-detail-id="${e.media.id}" style="background-image:url('${cover}')">
|
||||
<div class="status-pill">${statusLabel}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title open-detail" data-detail-id="${e.media.id}">${title}</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="ep-control">
|
||||
<button class="ep-minus">−</button>
|
||||
<span class="ep-value">${e.progress}</span>
|
||||
<button class="ep-plus">+</button>
|
||||
</div>
|
||||
<span class="ep-total">/ ${total || "?"}</span>
|
||||
</div>
|
||||
<div class="bar"><div class="bar-fill" style="width:${pct}%"></div></div>
|
||||
|
||||
<div class="meta-row">
|
||||
<div class="select-wrap"><select class="status-select">${statusOptions}</select></div>
|
||||
<div class="select-wrap"><select class="score-select">${scoreOptions}</select></div>
|
||||
<span class="save-status"></span>
|
||||
</div>
|
||||
|
||||
<div class="stream-row">${streamButtonsHtml(e.streamingLinks)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function wireCard(cardEl, entry) {
|
||||
const mediaId = entry.media.id;
|
||||
const total = entry.media.episodes || null;
|
||||
const epValueEl = cardEl.querySelector(".ep-value");
|
||||
const barFillEl = cardEl.querySelector(".bar-fill");
|
||||
const saveStatusEl = cardEl.querySelector(".save-status");
|
||||
let currentProgress = entry.progress;
|
||||
|
||||
async function setProgress(newVal) {
|
||||
if (newVal < 0) newVal = 0;
|
||||
if (total && newVal > total) newVal = total;
|
||||
currentProgress = newVal;
|
||||
epValueEl.textContent = newVal;
|
||||
if (total) barFillEl.style.width = Math.round((newVal / total) * 100) + "%";
|
||||
try {
|
||||
await window.dashboardApi.updateAnime(mediaId, { progress: newVal });
|
||||
flashSaved(saveStatusEl);
|
||||
} catch (e) {
|
||||
saveStatusEl.textContent = "✗";
|
||||
saveStatusEl.title = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
cardEl.querySelector(".ep-minus").addEventListener("click", () => setProgress(currentProgress - 1));
|
||||
cardEl.querySelector(".ep-plus").addEventListener("click", () => setProgress(currentProgress + 1));
|
||||
|
||||
cardEl.querySelector(".status-select").addEventListener("change", async (e) => {
|
||||
try {
|
||||
await window.dashboardApi.updateAnime(mediaId, { status: e.target.value });
|
||||
flashSaved(saveStatusEl);
|
||||
} catch (err) {
|
||||
saveStatusEl.textContent = "✗";
|
||||
saveStatusEl.title = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
cardEl.querySelector(".score-select").addEventListener("change", async (e) => {
|
||||
try {
|
||||
await window.dashboardApi.updateAnime(mediaId, { score: parseFloat(e.target.value) });
|
||||
flashSaved(saveStatusEl);
|
||||
} catch (err) {
|
||||
saveStatusEl.textContent = "✗";
|
||||
saveStatusEl.title = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
cardEl.querySelectorAll(".stream-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const link = entry.streamingLinks[parseInt(btn.dataset.idx, 10)];
|
||||
if (link) window.dashboardApi.watchNow(link.url, link.trackable);
|
||||
});
|
||||
});
|
||||
|
||||
cardEl.querySelectorAll(".open-detail").forEach((el) => {
|
||||
el.addEventListener("click", () => openDetailModal(mediaId));
|
||||
});
|
||||
}
|
||||
|
||||
let cachedData = null;
|
||||
let activeStatusFilter = "ALL";
|
||||
let textFilter = "";
|
||||
|
||||
// Persist which sections are collapsed across app restarts (renderer
|
||||
// reloads on every dashboard tab switch/refresh, so without this the
|
||||
// "Abgeschlossen" section would re-expand every 30s poll).
|
||||
const COLLAPSE_KEY = "anilist-companion-collapsed-sections";
|
||||
function getCollapsedSet() {
|
||||
try {
|
||||
const raw = localStorage.getItem(COLLAPSE_KEY);
|
||||
if (raw === null) return new Set(["COMPLETED", "DROPPED"]); // sensible first-run default
|
||||
return new Set(JSON.parse(raw));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
function setCollapsedSet(set) {
|
||||
localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...set]));
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const data = await window.dashboardApi.fetchWatching();
|
||||
cachedData = data;
|
||||
renderFromCache();
|
||||
}
|
||||
|
||||
function renderFromCache() {
|
||||
const data = cachedData;
|
||||
const userEl = document.getElementById("user");
|
||||
const listEl = document.getElementById("list");
|
||||
if (!data.loggedIn) {
|
||||
userEl.textContent = "Nicht eingeloggt – starte den anilist-tracker Hub-Server";
|
||||
listEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
userEl.textContent = data.userName;
|
||||
if (!data.entries.length) {
|
||||
listEl.innerHTML = '<div class="empty">Deine Liste ist leer. Such oben nach etwas Neuem!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const statCounts = { CURRENT: 0, REPEATING: 0, PAUSED: 0, PLANNING: 0, COMPLETED: 0, DROPPED: 0 };
|
||||
for (const e of data.entries) statCounts[e.status] = (statCounts[e.status] || 0) + 1;
|
||||
const statLabels = [
|
||||
["CURRENT", "Schaue"], ["COMPLETED", "Fertig"], ["PLANNING", "Geplant"], ["DROPPED", "Abgebr."]
|
||||
];
|
||||
document.getElementById("stat-row").innerHTML = statLabels
|
||||
.map(([key, label]) => `<div class="stat-pill"><div class="num">${statCounts[key] || 0}</div><div class="lbl">${label}</div></div>`)
|
||||
.join("");
|
||||
|
||||
const q = textFilter.trim().toLowerCase();
|
||||
const filteredEntries = data.entries.filter((e) => {
|
||||
if (activeStatusFilter !== "ALL" && e.status !== activeStatusFilter) return false;
|
||||
if (q && !animeTitle(e.media).toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!filteredEntries.length) {
|
||||
listEl.innerHTML = '<div class="empty">Keine Treffer für diesen Filter.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = {
|
||||
CURRENT: [], REPEATING: [], PAUSED: [], PLANNING: [], COMPLETED: [], DROPPED: []
|
||||
};
|
||||
for (const e of filteredEntries) {
|
||||
(groups[e.status] || (groups[e.status] = [])).push(e);
|
||||
}
|
||||
|
||||
const sections = [
|
||||
["CURRENT", "Am Schauen"],
|
||||
["REPEATING", "Wiederholung"],
|
||||
["PAUSED", "Pausiert"],
|
||||
["PLANNING", "Geplant"],
|
||||
["COMPLETED", "Abgeschlossen"],
|
||||
["DROPPED", "Abgebrochen"]
|
||||
];
|
||||
|
||||
const collapsed = getCollapsedSet();
|
||||
|
||||
listEl.innerHTML = sections
|
||||
.filter(([key]) => groups[key] && groups[key].length)
|
||||
.map(([key, label]) => {
|
||||
const isCollapsed = collapsed.has(key);
|
||||
return `
|
||||
<h2 class="section-title ${isCollapsed ? "collapsed" : ""}" data-section="${key}">
|
||||
<span class="chevron">▾</span>${label} <span class="count">${groups[key].length}</span>
|
||||
</h2>
|
||||
<div class="grid ${isCollapsed ? "collapsed" : ""}" data-section-grid="${key}">
|
||||
${groups[key].map((e) => cardHtml(e, data.scoreFormat)).join("")}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
listEl.querySelectorAll(".section-title").forEach((headerEl) => {
|
||||
headerEl.addEventListener("click", () => {
|
||||
const key = headerEl.dataset.section;
|
||||
const gridEl = listEl.querySelector(`[data-section-grid="${key}"]`);
|
||||
const set = getCollapsedSet();
|
||||
const nowCollapsed = !headerEl.classList.contains("collapsed");
|
||||
headerEl.classList.toggle("collapsed", nowCollapsed);
|
||||
gridEl.classList.toggle("collapsed", nowCollapsed);
|
||||
if (nowCollapsed) set.add(key); else set.delete(key);
|
||||
setCollapsedSet(set);
|
||||
});
|
||||
});
|
||||
|
||||
listEl.querySelectorAll(".card").forEach((cardEl) => {
|
||||
const mediaId = parseInt(cardEl.dataset.mediaId, 10);
|
||||
const entry = filteredEntries.find((e) => e.media.id === mediaId);
|
||||
if (entry) wireCard(cardEl, entry);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("filter-text").addEventListener("input", (e) => {
|
||||
textFilter = e.target.value;
|
||||
if (cachedData) renderFromCache();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".filter-chip").forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
document.querySelectorAll(".filter-chip").forEach((c) => c.classList.remove("active"));
|
||||
chip.classList.add("active");
|
||||
activeStatusFilter = chip.dataset.status;
|
||||
if (cachedData) renderFromCache();
|
||||
});
|
||||
});
|
||||
|
||||
// Detail modal: fetches full AniList metadata (description, genres,
|
||||
// score, studio, air dates) for a single anime on demand, so cards
|
||||
// stay lightweight and this richer info loads only when clicked.
|
||||
const modalBackdrop = document.getElementById("modal-backdrop");
|
||||
const modalContent = document.getElementById("modal-content");
|
||||
|
||||
function closeModal() {
|
||||
modalBackdrop.classList.remove("open");
|
||||
}
|
||||
modalBackdrop.addEventListener("click", (e) => {
|
||||
if (e.target === modalBackdrop) closeModal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d || !d.year) return null;
|
||||
const parts = [d.year];
|
||||
if (d.month) parts.unshift(String(d.month).padStart(2, "0"));
|
||||
if (d.day) parts.unshift(String(d.day).padStart(2, "0"));
|
||||
return parts.reverse().join(".");
|
||||
}
|
||||
|
||||
const FORMAT_LABELS = { TV: "TV", TV_SHORT: "TV Short", MOVIE: "Film", OVA: "OVA", ONA: "ONA", SPECIAL: "Special" };
|
||||
|
||||
async function openDetailModal(mediaId) {
|
||||
modalContent.innerHTML = '<div class="modal-loading">Lade Details…</div>';
|
||||
modalBackdrop.classList.add("open");
|
||||
try {
|
||||
const m = await window.dashboardApi.fetchMediaDetail(mediaId);
|
||||
const title = m.title.english || m.title.romaji;
|
||||
const banner = m.bannerImage || m.coverImage.large;
|
||||
const studio = m.studios?.nodes?.[0]?.name;
|
||||
const start = formatDate(m.startDate);
|
||||
const end = formatDate(m.endDate);
|
||||
const airRange = start ? (end && end !== start ? `${start} – ${end}` : start) : null;
|
||||
const score = m.averageScore != null ? Math.round(m.averageScore) : null;
|
||||
|
||||
const chips = [
|
||||
FORMAT_LABELS[m.format] || m.format,
|
||||
m.episodes ? `${m.episodes} Ep.` : null,
|
||||
m.duration ? `${m.duration} Min./Ep.` : null,
|
||||
studio,
|
||||
airRange
|
||||
].filter(Boolean);
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<div class="modal-banner" style="background-image:url('${banner}')">
|
||||
<button class="modal-close" id="modal-close-btn">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-title-row">
|
||||
<img class="modal-cover" src="${m.coverImage.large}" />
|
||||
<div>
|
||||
<div class="modal-title">${title}</div>
|
||||
${m.title.native ? `<div class="modal-native">${m.title.native}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-meta-chips">
|
||||
${score != null ? `<span class="meta-chip score">★ ${score}%</span>` : ""}
|
||||
${chips.map((c) => `<span class="meta-chip">${c}</span>`).join("")}
|
||||
</div>
|
||||
<div class="modal-genres">
|
||||
${(m.genres || []).map((g) => `<span class="genre-tag">${g}</span>`).join("")}
|
||||
</div>
|
||||
<div class="modal-desc">${(m.description || "Keine Beschreibung verfügbar.").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, "")}</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("modal-close-btn").addEventListener("click", closeModal);
|
||||
} catch (e) {
|
||||
modalContent.innerHTML = `<div class="modal-loading">Fehler beim Laden: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
window.dashboardApi.onRefresh(() => render());
|
||||
setInterval(render, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
const { app, BrowserWindow, BrowserView, ipcMain, session, shell } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
// Path to the MAL-Sync extension we built earlier from the real MALSync
|
||||
// source (github.com/MALSync/MALSync). Electron can load unpacked Chrome
|
||||
// extensions directly via session.loadExtension, so the SAME extension
|
||||
// code that runs in Brave also runs here, inside our own app's browser
|
||||
// tab - MAL-Sync itself is unmodified, just hosted in our own window
|
||||
// instead of a general-purpose browser.
|
||||
const MALSYNC_EXTENSION_PATH = path.join(
|
||||
require("os").homedir(),
|
||||
"projects/MALSync/dist/webextension"
|
||||
);
|
||||
|
||||
// Shares the same config file as our existing anilist-tracker hub server
|
||||
// (~/.anilist-tracker/config.json), so this app reads the SAME AniList
|
||||
// access token you already logged in with - no separate login needed.
|
||||
const CONFIG_PATH = path.join(os.homedir(), ".anilist-tracker", "config.json");
|
||||
|
||||
function loadHubConfig() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const WATCHING_QUERY = `
|
||||
query ($userId: Int) {
|
||||
MediaListCollection(userId: $userId, type: ANIME, status_in: [CURRENT, REPEATING, COMPLETED, PAUSED, DROPPED, PLANNING]) {
|
||||
user { mediaListOptions { scoreFormat } }
|
||||
lists {
|
||||
entries {
|
||||
id
|
||||
progress
|
||||
status
|
||||
score
|
||||
updatedAt
|
||||
media {
|
||||
id
|
||||
title { romaji english }
|
||||
episodes
|
||||
coverImage { medium large }
|
||||
bannerImage
|
||||
siteUrl
|
||||
externalLinks { site url type language icon color }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MEDIA_DETAIL_QUERY = `
|
||||
query ($id: Int) {
|
||||
Media(id: $id) {
|
||||
id
|
||||
title { romaji english native }
|
||||
description(asHtml: false)
|
||||
genres
|
||||
averageScore
|
||||
meanScore
|
||||
popularity
|
||||
status
|
||||
format
|
||||
season
|
||||
seasonYear
|
||||
episodes
|
||||
duration
|
||||
startDate { year month day }
|
||||
endDate { year month day }
|
||||
studios(isMain: true) { nodes { name } }
|
||||
coverImage { large }
|
||||
bannerImage
|
||||
externalLinks { site url type color }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function fetchMediaDetail(mediaId) {
|
||||
const cfg = loadHubConfig();
|
||||
const res = await fetch("https://graphql.anilist.co", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...(cfg?.accessToken ? { Authorization: `Bearer ${cfg.accessToken}` } : {})
|
||||
},
|
||||
body: JSON.stringify({ query: MEDIA_DETAIL_QUERY, variables: { id: mediaId } })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.errors) throw new Error(json.errors.map((e) => e.message).join(", "));
|
||||
return json.data.Media;
|
||||
}
|
||||
|
||||
// Streaming platforms we actually know how to route into our own
|
||||
// embedded "Streamen" browser tab with MAL-Sync tracking active (site
|
||||
// keys map 1:1 to MAL-Sync's supported page list). Anything AniList
|
||||
// reports outside this set still gets a working link, just opened in the
|
||||
// user's default system browser instead (shell.openExternal) since we
|
||||
// can't guarantee MAL-Sync tracks it inside our own window.
|
||||
const TRACKED_STREAMING_SITES = new Set([
|
||||
"crunchyroll.com",
|
||||
"netflix.com",
|
||||
"hidive.com",
|
||||
"hulu.com",
|
||||
"animationdigitalnetwork.com",
|
||||
"bilibili.tv",
|
||||
"iq.com",
|
||||
"disneyplus.com",
|
||||
"primevideo.com",
|
||||
"amazon.com"
|
||||
]);
|
||||
|
||||
function isTrackedHost(url) {
|
||||
try {
|
||||
const host = new URL(url).hostname.replace(/^www\./, "");
|
||||
return [...TRACKED_STREAMING_SITES].some((h) => host === h || host.endsWith("." + h));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Runs in the MAIN process (full Node + network access, no renderer
|
||||
// sandbox/CSP quirks to fight) and is exposed to the dashboard view via
|
||||
// ipcMain.handle below - the standard, reliable Electron pattern for
|
||||
// renderer-to-network calls.
|
||||
async function fetchWatching() {
|
||||
const cfg = loadHubConfig();
|
||||
if (!cfg || !cfg.accessToken || !cfg.userId) {
|
||||
return { loggedIn: false };
|
||||
}
|
||||
const res = await fetch("https://graphql.anilist.co", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${cfg.accessToken}`
|
||||
},
|
||||
body: JSON.stringify({ query: WATCHING_QUERY, variables: { userId: cfg.userId } })
|
||||
});
|
||||
const json = await res.json();
|
||||
const collection = json.data?.MediaListCollection;
|
||||
const entries = (collection?.lists || []).flatMap((l) => l.entries);
|
||||
entries.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
// Attach only the REAL streaming availability for each entry (AniList's
|
||||
// own externalLinks with type STREAMING) - this is what lets the
|
||||
// dashboard show "only where it's actually available" instead of a
|
||||
// fixed site picker, and lets us jump straight to the show's real page
|
||||
// instead of a generic site search.
|
||||
for (const e of entries) {
|
||||
const links = (e.media.externalLinks || []).filter((l) => l.type === "STREAMING");
|
||||
e.streamingLinks = links.map((l) => ({
|
||||
site: l.site,
|
||||
url: l.url,
|
||||
color: l.color,
|
||||
icon: l.icon,
|
||||
trackable: isTrackedHost(l.url)
|
||||
}));
|
||||
}
|
||||
// scoreFormat tells the dashboard how to render/collect the score input
|
||||
// (POINT_5 = 0-5 stars, POINT_10 = 0-10, POINT_100 = 0-100, etc.) - the
|
||||
// raw `score` field on each entry is ALREADY in this format, no
|
||||
// conversion needed as long as we send updates back in the same format.
|
||||
const scoreFormat = collection?.user?.mediaListOptions?.scoreFormat || "POINT_10";
|
||||
return { loggedIn: true, userName: cfg.userName, entries, scoreFormat };
|
||||
}
|
||||
|
||||
const SEARCH_QUERY = `
|
||||
query ($search: String) {
|
||||
Page(page: 1, perPage: 10) {
|
||||
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
|
||||
id
|
||||
title { romaji english }
|
||||
episodes
|
||||
status
|
||||
coverImage { medium }
|
||||
mediaListEntry { id status progress }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ADD_MUTATION = `
|
||||
mutation ($mediaId: Int, $status: MediaListStatus) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, status: $status) {
|
||||
id
|
||||
status
|
||||
progress
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
async function anilistRequest(query, variables) {
|
||||
const cfg = loadHubConfig();
|
||||
if (!cfg || !cfg.accessToken) {
|
||||
throw new Error("Nicht eingeloggt - Hub-Server-Login fehlt.");
|
||||
}
|
||||
const res = await fetch("https://graphql.anilist.co", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${cfg.accessToken}`
|
||||
},
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.errors) {
|
||||
throw new Error(json.errors.map((e) => e.message).join(", "));
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function searchAnime(search) {
|
||||
const data = await anilistRequest(SEARCH_QUERY, { search });
|
||||
return data.Page.media;
|
||||
}
|
||||
|
||||
// status: "PLANNING" | "CURRENT" | ... (AniList MediaListStatus enum)
|
||||
async function addAnimeToList(mediaId, status = "PLANNING") {
|
||||
const data = await anilistRequest(ADD_MUTATION, { mediaId, status });
|
||||
return data.SaveMediaListEntry;
|
||||
}
|
||||
|
||||
const UPDATE_MUTATION = `
|
||||
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus, $score: Float) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status, score: $score) {
|
||||
id
|
||||
progress
|
||||
status
|
||||
score
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Partial update: only fields the caller actually passed get sent to
|
||||
// AniList (undefined fields are omitted from the GraphQL variables so
|
||||
// they don't get overwritten with null) - lets the dashboard update just
|
||||
// the episode count, just the score, or just the status independently.
|
||||
async function updateAnimeEntry(mediaId, { progress, status, score } = {}) {
|
||||
const variables = { mediaId };
|
||||
if (progress !== undefined) variables.progress = progress;
|
||||
if (status !== undefined) variables.status = status;
|
||||
if (score !== undefined) variables.score = score;
|
||||
const data = await anilistRequest(UPDATE_MUTATION, variables);
|
||||
return data.SaveMediaListEntry;
|
||||
}
|
||||
|
||||
// Maps a streaming URL's hostname to one of our persistent tab views, so
|
||||
// "watch-now" can switch to the RIGHT tab (Crunchyroll link -> Crunchyroll
|
||||
// tab, Netflix link -> Netflix tab, etc.) instead of always reusing one.
|
||||
function siteIdForUrl(url) {
|
||||
try {
|
||||
const host = new URL(url).hostname.replace(/^www\./, "");
|
||||
if (host.endsWith("crunchyroll.com")) return "crunchyroll";
|
||||
if (host.endsWith("netflix.com")) return "netflix";
|
||||
if (host.endsWith("disneyplus.com")) return "disneyplus";
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle("fetch-watching", () => fetchWatching());
|
||||
ipcMain.handle("search-anime", (event, search) => searchAnime(search));
|
||||
ipcMain.handle("add-anime", (event, mediaId, status) => addAnimeToList(mediaId, status));
|
||||
ipcMain.handle("update-anime", (event, mediaId, fields) => updateAnimeEntry(mediaId, fields));
|
||||
ipcMain.handle("fetch-media-detail", (event, mediaId) => fetchMediaDetail(mediaId));
|
||||
// "Schauen" button on a dashboard card: opens the REAL streaming URL for
|
||||
// that exact show (from AniList's own externalLinks), not a site search.
|
||||
// If it's one of our own tabbed sites (Crunchyroll/Netflix/Disney+), we
|
||||
// switch to that tab and navigate it there so MAL-Sync can track it;
|
||||
// otherwise open in the user's default browser.
|
||||
ipcMain.handle("watch-now", (event, { url, trackable }) => {
|
||||
const siteId = siteIdForUrl(url);
|
||||
if (trackable && siteId && streamingViews[siteId]) {
|
||||
streamingViews[siteId].webContents.loadURL(url);
|
||||
activeTab = siteId;
|
||||
layoutViews();
|
||||
// Keep the tab bar's own highlighted tab in sync, since this switch
|
||||
// was triggered from the dashboard view, not a click on the tab bar.
|
||||
tabBarView.webContents.send("activate-tab", siteId);
|
||||
return { ok: true, opened: siteId };
|
||||
}
|
||||
shell.openExternal(url);
|
||||
return { ok: true, opened: "external" };
|
||||
});
|
||||
|
||||
let mainWindow;
|
||||
let dashboardView;
|
||||
let tabBarView;
|
||||
let browserView;
|
||||
let activeTab = "dashboard";
|
||||
|
||||
// Multiple streaming site tabs, each a persistent BrowserView so
|
||||
// switching between Crunchyroll/Netflix/Disney+ keeps its own login
|
||||
// session, scroll position and playback state instead of reloading.
|
||||
const STREAMING_SITES = [
|
||||
{ id: "crunchyroll", label: "Crunchyroll", url: "https://www.crunchyroll.com" },
|
||||
{ id: "netflix", label: "Netflix", url: "https://www.netflix.com" },
|
||||
{ id: "disneyplus", label: "Disney+", url: "https://www.disneyplus.com" }
|
||||
];
|
||||
const streamingViews = {}; // id -> BrowserView
|
||||
|
||||
function layoutViews() {
|
||||
if (!mainWindow) return;
|
||||
const [width, height] = mainWindow.getContentSize();
|
||||
const tabBarHeight = 44;
|
||||
const bounds = { x: 0, y: tabBarHeight, width, height: height - tabBarHeight };
|
||||
const hidden = { x: 0, y: 0, width: 0, height: 0 };
|
||||
|
||||
dashboardView.setBounds(activeTab === "dashboard" ? bounds : hidden);
|
||||
for (const [id, view] of Object.entries(streamingViews)) {
|
||||
view.setBounds(activeTab === id ? bounds : hidden);
|
||||
}
|
||||
}
|
||||
|
||||
const WINDOW_STATE_PATH = path.join(os.homedir(), ".anilist-tracker", "window-state.json");
|
||||
|
||||
function loadWindowState() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(WINDOW_STATE_PATH, "utf8"));
|
||||
} catch {
|
||||
return { width: 1280, height: 860 }; // first-launch default
|
||||
}
|
||||
}
|
||||
|
||||
function saveWindowState(win) {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
const bounds = win.isMaximized() || win.isFullScreen() ? win.getNormalBounds() : win.getBounds();
|
||||
const state = {
|
||||
...bounds,
|
||||
isMaximized: win.isMaximized(),
|
||||
isFullScreen: win.isFullScreen()
|
||||
};
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(WINDOW_STATE_PATH), { recursive: true });
|
||||
fs.writeFileSync(WINDOW_STATE_PATH, JSON.stringify(state));
|
||||
} catch (err) {
|
||||
console.error("Failed to save window state:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
// Load the real MAL-Sync extension into this app's own session so its
|
||||
// background service worker + content scripts run against pages opened
|
||||
// in browserView below.
|
||||
try {
|
||||
await session.defaultSession.loadExtension(MALSYNC_EXTENSION_PATH, {
|
||||
allowFileAccess: true
|
||||
});
|
||||
console.log("MAL-Sync extension loaded into app session");
|
||||
} catch (err) {
|
||||
console.error("Failed to load MAL-Sync extension:", err.message);
|
||||
}
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
...loadWindowState(),
|
||||
title: "AniList Companion",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "tabbar-preload.js"),
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
|
||||
if (loadWindowState().isMaximized) mainWindow.maximize();
|
||||
if (loadWindowState().isFullScreen) mainWindow.setFullScreen(true);
|
||||
|
||||
// Debounced save so dragging/resizing doesn't hammer disk writes -
|
||||
// only the LAST bounds after the user stops moving/resizing gets saved.
|
||||
let saveStateTimer = null;
|
||||
const scheduleSaveState = () => {
|
||||
clearTimeout(saveStateTimer);
|
||||
saveStateTimer = setTimeout(() => saveWindowState(mainWindow), 400);
|
||||
};
|
||||
mainWindow.on("move", scheduleSaveState);
|
||||
mainWindow.on("resize", scheduleSaveState);
|
||||
mainWindow.on("close", () => saveWindowState(mainWindow));
|
||||
|
||||
// Thin tab bar at the top (dashboard vs. streaming-with-tracking),
|
||||
// rendered as its own tiny BrowserView so it stays visible regardless
|
||||
// of what the browser tab is doing.
|
||||
tabBarView = new BrowserView({
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "tabbar-preload.js"),
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
mainWindow.addBrowserView(tabBarView);
|
||||
tabBarView.setBounds({ x: 0, y: 0, width: 1280, height: 44 });
|
||||
tabBarView.setAutoResize({ width: true });
|
||||
tabBarView.webContents.loadFile(path.join(__dirname, "tabbar.html"));
|
||||
|
||||
dashboardView = new BrowserView({
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "dashboard-preload.js"),
|
||||
contextIsolation: true
|
||||
}
|
||||
});
|
||||
mainWindow.addBrowserView(dashboardView);
|
||||
dashboardView.webContents.loadFile(path.join(__dirname, "dashboard.html"));
|
||||
|
||||
// One persistent BrowserView per streaming site, each loaded once at
|
||||
// startup and kept alive when switching tabs (so login sessions,
|
||||
// scroll position, and any in-progress video keep playing in the
|
||||
// background instead of being torn down every tab switch).
|
||||
for (const site of STREAMING_SITES) {
|
||||
const view = new BrowserView({
|
||||
webPreferences: { contextIsolation: true }
|
||||
});
|
||||
mainWindow.addBrowserView(view);
|
||||
view.webContents.loadURL(site.url);
|
||||
// Network traffic capture (CDP), used to discover streaming sites'
|
||||
// real internal API endpoints/IDs for a more precise AniList
|
||||
// mapping. Only observes traffic this app's own embedded browser
|
||||
// makes - not a system-wide proxy, doesn't touch other apps.
|
||||
setupTrafficCapture(view.webContents);
|
||||
streamingViews[site.id] = view;
|
||||
}
|
||||
browserView = streamingViews.crunchyroll; // default target for watch-now IPC
|
||||
|
||||
layoutViews();
|
||||
mainWindow.on("resize", () => {
|
||||
const [width] = mainWindow.getContentSize();
|
||||
tabBarView.setBounds({ x: 0, y: 0, width, height: 44 });
|
||||
layoutViews();
|
||||
});
|
||||
|
||||
// IPC-free tab switching: the tab bar page posts a message via
|
||||
// executeJavaScript-triggered navigation event we listen for.
|
||||
tabBarView.webContents.on("ipc-message", (event, channel, tab) => {
|
||||
if (channel === "switch-tab") {
|
||||
activeTab = tab;
|
||||
layoutViews();
|
||||
if (tab === "dashboard") dashboardView.webContents.send("refresh");
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
const TRAFFIC_LOG_PATH = path.join(os.homedir(), ".anilist-tracker", "cr-traffic.jsonl");
|
||||
|
||||
// Only log requests to hosts that are actually part of Crunchyroll's own
|
||||
// API surface - excludes ads/analytics/CDN noise so the log stays
|
||||
// readable and focused on discovering their internal show/episode IDs.
|
||||
const CR_API_HOST_PATTERN = /(^|\.)crunchyroll\.com$/i;
|
||||
|
||||
function setupTrafficCapture(webContents) {
|
||||
const dbg = webContents.debugger;
|
||||
try {
|
||||
dbg.attach("1.3");
|
||||
} catch (err) {
|
||||
console.error("Traffic capture: failed to attach debugger:", err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
dbg.sendCommand("Network.enable").catch(() => {});
|
||||
|
||||
const pendingRequests = new Map(); // requestId -> { url, method }
|
||||
|
||||
dbg.on("message", (event, method, params) => {
|
||||
if (method === "Network.requestWillBeSent") {
|
||||
const url = params.request?.url || "";
|
||||
let host;
|
||||
try {
|
||||
host = new URL(url).hostname;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!CR_API_HOST_PATTERN.test(host)) return;
|
||||
// Only care about actual API/data calls, not asset loads
|
||||
// (images/fonts/scripts) - keeps the log to the interesting stuff.
|
||||
if (/\.(jpg|jpeg|png|gif|svg|woff2?|ttf|css|js|ico)(\?|$)/i.test(url)) return;
|
||||
pendingRequests.set(params.requestId, {
|
||||
url,
|
||||
method: params.request.method,
|
||||
postData: params.request.postData || null
|
||||
});
|
||||
} else if (method === "Network.responseReceived") {
|
||||
const pending = pendingRequests.get(params.requestId);
|
||||
if (!pending) return;
|
||||
pending.status = params.response.status;
|
||||
pending.mimeType = params.response.mimeType;
|
||||
} else if (method === "Network.loadingFinished") {
|
||||
const pending = pendingRequests.get(params.requestId);
|
||||
if (!pending) return;
|
||||
pendingRequests.delete(params.requestId);
|
||||
// Fetch the actual response body via CDP, then append a structured
|
||||
// record to the traffic log. Errors here (body already discarded,
|
||||
// etc.) are swallowed - we still keep the request metadata.
|
||||
dbg
|
||||
.sendCommand("Network.getResponseBody", { requestId: params.requestId })
|
||||
.then((body) => {
|
||||
appendTrafficRecord({ ...pending, responseBody: body.body, base64: body.base64Encoded });
|
||||
})
|
||||
.catch(() => {
|
||||
appendTrafficRecord(pending);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
webContents.on("destroyed", () => {
|
||||
try {
|
||||
dbg.detach();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
function appendTrafficRecord(record) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(TRAFFIC_LOG_PATH), { recursive: true });
|
||||
fs.appendFileSync(
|
||||
TRAFFIC_LOG_PATH,
|
||||
JSON.stringify({ time: new Date().toISOString(), ...record }) + "\n"
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Traffic capture: failed to write log:", err.message);
|
||||
}
|
||||
// Crunchyroll's series-level CMS object (content/v2/cms/objects/<id>)
|
||||
// is the most reliable source of the real series title for a bare
|
||||
// series_id - populate our id->title cache whenever we observe one,
|
||||
// so later up_next events (which usually just have the episode title)
|
||||
// can resolve back to the correct series name.
|
||||
if (record.url && record.url.includes("/content/v2/cms/objects/") && record.responseBody) {
|
||||
try {
|
||||
const json = JSON.parse(record.responseBody);
|
||||
for (const item of json.data || []) {
|
||||
if (item.id && item.title && item.type === "series") {
|
||||
seriesIdToTitle.set(item.id, item.title);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse failures, other capture paths still work
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time extraction: Crunchyroll's own content/v2/discover/up_next
|
||||
// API returns an authoritative "identifier" string in the shape
|
||||
// "<seriesId>|<seasonId>|E<episodeNumber>" plus a human-readable
|
||||
// slug_title and title - this is far more precise than scraping page
|
||||
// text (it's literally what Crunchyroll's own frontend uses), so we
|
||||
// feed it straight into the hub server as a structured event instead
|
||||
// of relying on the DOM-scraping content script for this site.
|
||||
if (record.url && record.url.includes("/content/v2/discover/up_next/") && record.responseBody) {
|
||||
try {
|
||||
const json = JSON.parse(record.responseBody);
|
||||
const panel = json.data?.[0]?.panel;
|
||||
const meta = panel?.episode_metadata;
|
||||
if (panel && meta?.identifier) {
|
||||
const [seriesId, seasonId, episodeTag] = meta.identifier.split("|");
|
||||
const episodeNumber = meta.episode_number ?? parseInt((episodeTag || "").replace(/^E/, ""), 10);
|
||||
reportCrunchyrollApiEvent({
|
||||
seriesId,
|
||||
seasonId,
|
||||
episodeNumber,
|
||||
episodeTitle: panel.title,
|
||||
seriesTitle: meta.season_title || panel.title
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, or shape changed - ignore, other capture paths still work.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Crunchyroll's series-level CMS object (content/v2/cms/objects/<id>)
|
||||
// gives us the real series title for a bare series_id, since up_next
|
||||
// only reliably has the EPISODE title. Cache id->title as we observe it.
|
||||
const seriesIdToTitle = new Map();
|
||||
|
||||
function reportCrunchyrollApiEvent({ seriesId, seasonId, episodeNumber, episodeTitle, seriesTitle }) {
|
||||
const knownTitle = seriesIdToTitle.get(seriesId);
|
||||
const title = knownTitle || seriesTitle || episodeTitle;
|
||||
if (!title || episodeNumber == null) return;
|
||||
fetch("http://localhost:9632/event", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceId: "app-cr-api",
|
||||
rawTitle: title,
|
||||
episode: episodeNumber,
|
||||
force: false
|
||||
})
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
// Note: this BrowserView's webContents fires an "ipc-message" event in
|
||||
// the main process whenever ipcRenderer.send() is called here - NOT
|
||||
// sendToHost (that API only exists for the legacy <webview> tag).
|
||||
contextBridge.exposeInMainWorld("tabbar", {
|
||||
switchTab: (tab) => ipcRenderer.send("switch-tab", tab),
|
||||
navigate: (url) => ipcRenderer.send("navigate", url),
|
||||
onActivateTab: (cb) => ipcRenderer.on("activate-tab", (event, tab) => cb(tab))
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #14141c;
|
||||
-webkit-app-region: drag;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px 0 84px;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.tab {
|
||||
-webkit-app-region: no-drag;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
color: #8a8a9a;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.tab .swatch {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.tab:hover { background: rgba(255,255,255,0.05); color: #ccc; }
|
||||
.tab.active { background: rgba(124,92,255,0.16); color: #fff; }
|
||||
.tab.active .swatch { box-shadow: 0 0 6px currentColor; }
|
||||
.spacer { flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="tab active" id="tab-dashboard"><span class="swatch" style="background:#7c5cff;color:#7c5cff"></span>Meine Liste</button>
|
||||
<button class="tab" data-tab="crunchyroll"><span class="swatch" style="background:#f47521;color:#f47521"></span>Crunchyroll</button>
|
||||
<button class="tab" data-tab="netflix"><span class="swatch" style="background:#e50914;color:#e50914"></span>Netflix</button>
|
||||
<button class="tab" data-tab="disneyplus"><span class="swatch" style="background:#1a76d1;color:#1a76d1"></span>Disney+</button>
|
||||
<div class="spacer"></div>
|
||||
<script>
|
||||
const allTabButtons = () => document.querySelectorAll(".tab");
|
||||
|
||||
function activateTabButton(btn) {
|
||||
allTabButtons().forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
}
|
||||
|
||||
document.getElementById("tab-dashboard").addEventListener("click", (e) => {
|
||||
activateTabButton(e.currentTarget);
|
||||
window.tabbar.switchTab("dashboard");
|
||||
});
|
||||
|
||||
document.querySelectorAll(".tab[data-tab]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
activateTabButton(e.currentTarget);
|
||||
window.tabbar.switchTab(btn.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
// The dashboard's "Anschauen" buttons can switch tabs directly (main
|
||||
// process), so reflect that here too - otherwise the tab bar would
|
||||
// still show "Meine Liste" as active while a streaming tab is shown.
|
||||
window.tabbar.onActivateTab((tab) => {
|
||||
const btn = tab === "dashboard"
|
||||
? document.getElementById("tab-dashboard")
|
||||
: document.querySelector(`.tab[data-tab="${tab}"]`);
|
||||
if (btn) activateTabButton(btn);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user