23 Commits

Author SHA1 Message Date
Peifan Li
35aa348824 chore(release): v1.7.26 2026-01-03 11:39:55 -05:00
Peifan Li
1b9451bffa feat: Add script to reset password securely 2026-01-03 11:38:31 -05:00
Peifan Li
9968268975 feat: Add allowResetPassword setting and UI components 2026-01-03 11:23:03 -05:00
Peifan Li
ce544ff9c2 feat: Add password login permission handling 2026-01-03 11:05:42 -05:00
Peifan Li
b6e3072350 chore(release): v1.7.25 2026-01-02 23:45:02 -05:00
Peifan Li
85424624ca feat: Add passkey feature and refactor formatUtils 2026-01-02 23:44:20 -05:00
Peifan Li
6fdfa90d01 feat: add passkey feature 2026-01-02 23:42:56 -05:00
Peifan Li
c9657bad51 refactor: Update formatUtils to use formatRelativeDownloadTime function 2026-01-02 13:25:02 -05:00
Peifan Li
2d9d7b37a6 chore(release): v1.7.24 2026-01-01 12:16:05 -05:00
Peifan Li
b8fcb05d51 refactor: Explicitly preserve network-related options 2026-01-01 12:15:20 -05:00
Peifan Li
90a24454f6 refactor: Explicitly preserve network-related options 2026-01-01 12:13:19 -05:00
Peifan Li
a56de30dd1 chore(release): v1.7.23 2026-01-01 11:31:14 -05:00
Peifan Li
b8cc540f9d fix: Correct version number in CHANGELOG to v1.7.23 2026-01-01 11:29:56 -05:00
Peifan Li
b546a4520e feat: Add new features and dependencies updates 2026-01-01 11:29:27 -05:00
Peifan Li
6bbb40eb11 feat: Add logic to refresh thumbnail with random timestamp 2026-01-01 11:27:07 -05:00
Peifan Li
c00b552ba9 feat: Add reset password route and update dependencies 2026-01-01 11:17:15 -05:00
Peifan Li
845e1847f7 feat: Add reset password route 2026-01-01 11:15:02 -05:00
Peifan Li
71d59a9e26 Merge pull request #53 from franklioxygen/snyk-fix-6d6192da51ce3a14e4e8b5488c3c7e83 2025-12-31 00:22:38 -05:00
Peifan Li
4e8d7553ea chore(release): v1.7.22 2025-12-30 23:09:09 -05:00
Peifan Li
e1fb345094 feat: Add risk command scanning for hook uploads 2025-12-30 23:08:30 -05:00
Peifan Li
351f1876d7 refactor: Improve handling of absolute paths in security functions 2025-12-30 23:06:50 -05:00
Peifan Li
c32fa3e7ca feat: Add risk command scanning for hook uploads 2025-12-30 23:00:38 -05:00
snyk-bot
b0428b9813 fix: backend/package.json & backend/package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-QS-14724253
2025-12-31 03:26:55 +00:00
47 changed files with 3364 additions and 593 deletions

View File

@@ -1,6 +1,54 @@
# Change Log
## v1.7.25 (2026-01-02)
### Feat
- feat: add passkey feature (6fdfa90)
### Refactor
- refactor: Update formatUtils to use formatRelativeDownloadTime function (c9657ba)
## v1.7.24 (2026-01-01)
### Refactor
- refactor: Explicitly preserve network-related options (90a2445)
## v1.7.23 (2026-01-01)
### Feat
- feat: Add logic to refresh thumbnail with random timestamp (6bbb40e)
- feat: Add reset password route and update dependencies (c00b552)
### Feat
- feat: Add reset password route (845e184)
### Fix
- fix: backend/package.json & backend/package-lock.json to reduce vulnerabilities (b0428b9)
## v1.7.22 (2025-12-30)
### Feat
- feat: Add risk command scanning for hook uploads (c32fa3e)
### Refactor
- refactor: Improve handling of absolute paths in security functions (351f187)
## v1.7.21 (2025-12-30)
### Feat
- feat: Add hook functionality for task lifecycle (6f1a1cd)
- feat: add task hooks (8ac9e99)
## v1.7.20 (2025-12-30)
### Chore

View File

@@ -1,15 +1,16 @@
{
"name": "backend",
"version": "1.7.21",
"version": "1.7.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"version": "1.7.21",
"version": "1.7.26",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@simplewebauthn/server": "^13.2.2",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.6",
@@ -17,7 +18,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.44.7",
"express": "^4.18.2",
"express": "^4.22.0",
"fs-extra": "^11.2.0",
"multer": "^2.0.2",
"node-cron": "^4.2.1",
@@ -628,6 +629,12 @@
"node": ">=18"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -791,6 +798,12 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -814,6 +827,165 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz",
"integrity": "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz",
"integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"@peculiar/asn1-x509-attr": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz",
"integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz",
"integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz",
"integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-pkcs8": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz",
"integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz",
"integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-pfx": "^2.6.0",
"@peculiar/asn1-pkcs8": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"@peculiar/asn1-x509-attr": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz",
"integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz",
"integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==",
"license": "MIT",
"dependencies": {
"asn1js": "^3.0.6",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz",
"integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"asn1js": "^3.0.6",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz",
"integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.2.tgz",
"integrity": "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1177,6 +1349,25 @@
"win32"
]
},
"node_modules/@simplewebauthn/server": {
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz",
"integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.3.10",
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-rsa": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/asn1-x509": "^2.3.8",
"@peculiar/x509": "^1.13.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
@@ -1808,6 +1999,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1js": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz",
"integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -3267,39 +3472,39 @@
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.0.tgz",
"integrity": "sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
@@ -3312,6 +3517,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -5109,6 +5329,24 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -5190,6 +5428,12 @@
"node": ">=8.10.0"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -6101,6 +6345,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.7.21",
"version": "1.7.26",
"main": "server.js",
"scripts": {
"start": "ts-node src/server.ts",
@@ -9,6 +9,7 @@
"generate": "drizzle-kit generate",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"reset-password": "ts-node scripts/reset-password.ts",
"postinstall": "node -e \"const fs = require('fs'); const cp = require('child_process'); const p = 'bgutil-ytdlp-pot-provider/server'; if (fs.existsSync(p)) { console.log('Building provider...'); cp.execSync('npm install && npx tsc', { cwd: p, stdio: 'inherit' }); } else { console.log('Skipping provider build: ' + p + ' not found'); }\""
},
"keywords": [],
@@ -16,6 +17,7 @@
"license": "ISC",
"description": "Backend for MyTube video streaming website",
"dependencies": {
"@simplewebauthn/server": "^13.2.2",
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.4.6",
@@ -23,7 +25,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.44.7",
"express": "^4.18.2",
"express": "^4.22.0",
"fs-extra": "^11.2.0",
"multer": "^2.0.2",
"node-cron": "^4.2.1",

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env ts-node
/**
* Script to directly reset password and enable password login in the database
*
* Usage:
* npm run reset-password [new-password]
* or
* ts-node scripts/reset-password.ts [new-password]
*
* If no password is provided, a random 8-character password will be generated.
* The script will:
* 1. Hash the password using bcrypt
* 2. Update the password in the settings table
* 3. Set passwordLoginAllowed to true
* 4. Set loginEnabled to true
* 5. Display the new password (if generated)
*
* Examples:
* npm run reset-password # Generate random password
* npm run reset-password mynewpassword123 # Set specific password
*/
import Database from "better-sqlite3";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import fs from "fs-extra";
import path from "path";
import dotenv from "dotenv";
// Load environment variables
dotenv.config();
// Determine database path
const ROOT_DIR = process.cwd();
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT_DIR, "data");
// Normalize and resolve paths to prevent path traversal
const normalizedDataDir = path.normalize(path.resolve(DATA_DIR));
const dbPath = path.normalize(path.resolve(normalizedDataDir, "mytube.db"));
// Validate that the database path is within the expected directory
// This prevents path traversal attacks via environment variables
const resolvedDataDir = path.resolve(normalizedDataDir);
const resolvedDbPath = path.resolve(dbPath);
if (!resolvedDbPath.startsWith(resolvedDataDir + path.sep) && resolvedDbPath !== resolvedDataDir) {
console.error("Error: Invalid database path detected (path traversal attempt)");
process.exit(1);
}
/**
* Configure SQLite database for compatibility
*/
function configureDatabase(db: Database.Database): void {
db.pragma("journal_mode = DELETE");
db.pragma("synchronous = NORMAL");
db.pragma("busy_timeout = 5000");
db.pragma("foreign_keys = ON");
}
/**
* Generate a random password
*/
function generateRandomPassword(length: number = 8): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const randomBytes = crypto.randomBytes(length);
return Array.from(randomBytes, (byte) => chars.charAt(byte % chars.length)).join("");
}
/**
* Hash a password using bcrypt
*/
async function hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10);
return await bcrypt.hash(password, salt);
}
/**
* Main function to reset password and enable password login
*/
async function resetPassword(newPassword?: string): Promise<void> {
// Check if database exists
if (!fs.existsSync(dbPath)) {
console.error(`Error: Database not found at ${dbPath}`);
console.error("Please ensure the MyTube backend has been started at least once.");
process.exit(1);
}
// Generate password if not provided
const password = newPassword || generateRandomPassword(8);
const isGenerated = !newPassword;
// Hash the password
console.log("Hashing password...");
const hashedPassword = await hashPassword(password);
// Connect to database
console.log(`Connecting to database at ${dbPath}...`);
const db = new Database(dbPath);
configureDatabase(db);
try {
// Start transaction
db.transaction(() => {
// Update password
db.prepare(`
INSERT INTO settings (key, value)
VALUES ('password', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`).run(JSON.stringify(hashedPassword));
// Set passwordLoginAllowed to true
db.prepare(`
INSERT INTO settings (key, value)
VALUES ('passwordLoginAllowed', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`).run(JSON.stringify(true));
// Set loginEnabled to true
db.prepare(`
INSERT INTO settings (key, value)
VALUES ('loginEnabled', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`).run(JSON.stringify(true));
})();
console.log("✓ Password reset successfully");
console.log("✓ Password login enabled");
console.log("✓ Login enabled");
if (isGenerated) {
console.log("\n" + "=".repeat(50));
console.log("NEW PASSWORD (save this securely):");
console.log(password);
console.log("=".repeat(50));
console.log("\n⚠ This password will not be shown again!");
} else {
console.log("\n✓ Password has been set to the provided value");
}
} catch (error) {
console.error("Error updating database:", error);
process.exit(1);
} finally {
db.close();
}
}
// Parse command line arguments
const args = process.argv.slice(2);
const providedPassword = args[0];
// Run the script
resetPassword(providedPassword).catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});

View File

@@ -2,10 +2,14 @@
import { Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as videoMetadataController from '../../controllers/videoMetadataController';
import * as metadataService from '../../services/metadataService';
import * as storageService from '../../services/storageService';
// Mock dependencies
vi.mock('../../services/storageService');
vi.mock('../../services/metadataService', () => ({
getVideoDuration: vi.fn()
}));
vi.mock('../../utils/security', () => ({
validateVideoPath: vi.fn((path) => path),
validateImagePath: vi.fn((path) => path),
@@ -108,4 +112,37 @@ describe('videoMetadataController', () => {
}));
});
});
describe('refreshThumbnail', () => {
it('should refresh thumbnail with random timestamp', async () => {
mockReq.params = { id: '123' };
const mockVideo = {
id: '123',
videoPath: '/videos/test.mp4',
thumbnailPath: '/images/test.jpg',
thumbnailFilename: 'test.jpg'
};
(storageService.getVideoById as any).mockReturnValue(mockVideo);
(metadataService.getVideoDuration as any).mockResolvedValue(100); // 100 seconds duration
await videoMetadataController.refreshThumbnail(mockReq as Request, mockRes as Response);
expect(storageService.getVideoById).toHaveBeenCalledWith('123');
expect(metadataService.getVideoDuration).toHaveBeenCalled();
// Verify execFileSafe was called with ffmpeg
// The exact arguments depend on the random timestamp, but we can verify the structure
const security = await import('../../utils/security');
expect(security.execFileSafe).toHaveBeenCalledWith(
'ffmpeg',
expect.arrayContaining([
'-i', expect.stringContaining('test.mp4'),
'-ss', expect.stringMatching(/^\d{2}:\d{2}:\d{2}$/),
'-vframes', '1',
expect.stringContaining('test.jpg'),
'-y'
])
);
});
});
});

View File

@@ -21,6 +21,16 @@ describe('security', () => {
it('should return false for traversal', () => {
expect(security.validatePathWithinDirectory('/base/../other/file.txt', '/base')).toBe(false);
});
it('should handle absolute paths correctly without duplication', () => {
// Mock path.resolve to behave predictably for testing logic if needed,
// but here we rely on the implementation fix.
// This tests that if we pass an absolute path that is valid, it returns true.
// The critical part is that it doesn't fail internally or double-resolve.
const absPath = '/Users/user/project/backend/uploads/videos/test.mp4';
const allowedDir = '/Users/user/project/backend/uploads/videos';
expect(security.validatePathWithinDirectory(absPath, allowedDir)).toBe(true);
});
});
describe('validateUrl', () => {

View File

@@ -28,10 +28,54 @@ export const uploadHook = async (
throw new ValidationError("Invalid hook name", "name");
}
// Scan for risk commands
const riskCommand = scanForRiskCommands(req.file.path);
if (riskCommand) {
// Delete the file immediately
require("fs").unlinkSync(req.file.path);
throw new ValidationError(
`Risk command detected: ${riskCommand}. Upload rejected.`,
"file"
);
}
HookService.uploadHook(name, req.file.path);
res.json(successMessage(`Hook ${name} uploaded successfully`));
};
/**
* Scan file for risk commands
*/
const scanForRiskCommands = (filePath: string): string | null => {
const fs = require("fs");
const content = fs.readFileSync(filePath, "utf-8");
// List of risky patterns
// We use regex to match commands, trying to avoid false positives in comments if possible,
// but for safety, even commented dangerous commands might be flagged or we just accept strictness.
// A simple include check is safer for now.
const riskyPatterns = [
{ pattern: /rm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+|-[a-zA-Z]*f[a-zA-Z]*\s+)*-?[rf][a-zA-Z]*\s+.*[\/\*]/, name: "rm -rf / (recursive delete)" }, // Matches rm -rf /, rm -fr *, etc roughly
{ pattern: /mkfs/, name: "mkfs (format disk)" },
{ pattern: /dd\s+if=/, name: "dd (disk write)" },
{ pattern: /:[:\(\)\{\}\s|&]+;:/, name: "fork bomb" },
{ pattern: />\s*\/dev\/sd/, name: "write to block device" },
{ pattern: />\s*\/dev\/nvme/, name: "write to block device" },
{ pattern: /mv\s+.*[\s\/]+\//, name: "mv to root" }, // deeply simplified, but mv / is dangerous
{ pattern: /chmod\s+.*777\s+\//, name: "chmod 777 root" },
{ pattern: /wget\s+http/, name: "wget (potential malware download)" },
{ pattern: /curl\s+http/, name: "curl (potential malware download)" },
];
for (const risk of riskyPatterns) {
if (risk.pattern.test(content)) {
return risk.name;
}
}
return null;
};
/**
* Delete hook script
*/

View File

@@ -0,0 +1,183 @@
import { Request, Response } from "express";
import * as passkeyService from "../services/passkeyService";
/**
* Get all passkeys
* Errors are automatically handled by asyncHandler middleware
*/
export const getPasskeys = async (
_req: Request,
res: Response
): Promise<void> => {
const passkeys = passkeyService.getPasskeys();
// Don't send sensitive credential data to frontend
const safePasskeys = passkeys.map((p) => ({
id: p.id,
name: p.name,
createdAt: p.createdAt,
}));
res.json({ passkeys: safePasskeys });
};
/**
* Check if passkeys exist
* Errors are automatically handled by asyncHandler middleware
*/
export const checkPasskeysExist = async (
_req: Request,
res: Response
): Promise<void> => {
const passkeys = passkeyService.getPasskeys();
res.json({ exists: passkeys.length > 0 });
};
/**
* Get origin and RP ID from request
*/
function getOriginAndRPID(req: Request): { origin: string; rpID: string } {
// Get origin from headers
let origin = req.headers.origin;
if (!origin && req.headers.referer) {
// Extract origin from referer
try {
const refererUrl = new URL(req.headers.referer);
origin = refererUrl.origin;
} catch (e) {
origin = req.headers.referer;
}
}
if (!origin) {
const protocol =
req.headers["x-forwarded-proto"] || (req.secure ? "https" : "http");
const host = req.headers.host || "localhost:5550";
origin = `${protocol}://${host}`;
}
// Extract hostname for RP_ID
let hostname = "localhost";
try {
const originUrl = new URL(origin as string);
hostname = originUrl.hostname;
} catch (e) {
// Fallback: extract from host header
hostname = req.headers.host?.split(":")[0] || "localhost";
}
// RP_ID should be the domain name (without port)
// For localhost/127.0.0.1, use 'localhost', otherwise use the full hostname
const rpID =
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"
? "localhost"
: hostname;
return { origin: origin as string, rpID };
}
/**
* Generate registration options for creating a new passkey
* Errors are automatically handled by asyncHandler middleware
*/
export const generateRegistrationOptions = async (
req: Request,
res: Response
): Promise<void> => {
const userName = req.body.userName || "MyTube User";
const { origin, rpID } = getOriginAndRPID(req);
const result = await passkeyService.generatePasskeyRegistrationOptions(
userName,
origin,
rpID
);
res.json(result);
};
/**
* Verify and store a new passkey
* Errors are automatically handled by asyncHandler middleware
*/
export const verifyRegistration = async (
req: Request,
res: Response
): Promise<void> => {
const { body, challenge } = req.body;
if (!body || !challenge) {
res.status(400).json({ error: "Missing body or challenge" });
return;
}
const { origin, rpID } = getOriginAndRPID(req);
const result = await passkeyService.verifyPasskeyRegistration(
body,
challenge,
origin,
rpID
);
if (result.verified) {
res.json({ success: true, passkey: result.passkey });
} else {
res.status(400).json({ success: false, error: "Verification failed" });
}
};
/**
* Generate authentication options for passkey login
* Errors are automatically handled by asyncHandler middleware
*/
export const generateAuthenticationOptions = async (
req: Request,
res: Response
): Promise<void> => {
try {
const { rpID } = getOriginAndRPID(req);
const result = await passkeyService.generatePasskeyAuthenticationOptions(
rpID
);
res.json(result);
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : "No passkeys available",
});
}
};
/**
* Verify passkey authentication
* Errors are automatically handled by asyncHandler middleware
*/
export const verifyAuthentication = async (
req: Request,
res: Response
): Promise<void> => {
const { body, challenge } = req.body;
if (!body || !challenge) {
res.status(400).json({ error: "Missing body or challenge" });
return;
}
const { origin, rpID } = getOriginAndRPID(req);
const result = await passkeyService.verifyPasskeyAuthentication(
body,
challenge,
origin,
rpID
);
if (result.verified) {
res.json({ success: true });
} else {
res.status(401).json({ success: false, error: "Authentication failed" });
}
};
/**
* Remove all passkeys
* Errors are automatically handled by asyncHandler middleware
*/
export const removeAllPasskeys = async (
_req: Request,
res: Response
): Promise<void> => {
passkeyService.removeAllPasskeys();
res.json({ success: true });
};

View File

@@ -3,6 +3,7 @@ import fs from "fs-extra";
import path from "path";
import { IMAGES_DIR, VIDEOS_DIR } from "../config/paths";
import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
import { getVideoDuration } from "../services/metadataService";
import * as storageService from "../services/storageService";
import { logger } from "../utils/logger";
import { successResponse } from "../utils/response";
@@ -95,11 +96,31 @@ export const refreshThumbnail = async (
const validatedThumbnailPath = validateImagePath(thumbnailAbsolutePath);
fs.ensureDirSync(path.dirname(validatedThumbnailPath));
// Calculate random timestamp
let timestamp = "00:00:00";
try {
const duration = await getVideoDuration(validatedVideoPath);
if (duration && duration > 0) {
// Pick a random second, avoiding the very beginning and very end if possible
// But for simplicity and to match request "random frame", valid random second is fine.
// Let's ensure we don't go past the end.
const randomSecond = Math.floor(Math.random() * duration);
const hours = Math.floor(randomSecond / 3600);
const minutes = Math.floor((randomSecond % 3600) / 60);
const seconds = randomSecond % 60;
timestamp = `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
} catch (err) {
logger.warn("Failed to get video duration for random thumbnail, using default 00:00:00", err);
}
// Generate thumbnail using execFileSafe to prevent command injection
try {
await execFileSafe("ffmpeg", [
"-i", validatedVideoPath,
"-ss", "00:00:00",
"-ss", timestamp,
"-vframes", "1",
validatedThumbnailPath,
"-y"

View File

@@ -2,32 +2,43 @@ import express from "express";
import multer from "multer";
import os from "os";
import {
checkCookies,
deleteCookies,
uploadCookies,
checkCookies,
deleteCookies,
uploadCookies,
} from "../controllers/cookieController";
import {
cleanupBackupDatabases,
exportDatabase,
getLastBackupInfo,
importDatabase,
restoreFromLastBackup,
cleanupBackupDatabases,
exportDatabase,
getLastBackupInfo,
importDatabase,
restoreFromLastBackup,
} from "../controllers/databaseBackupController";
import {
deleteHook,
getHookStatus,
uploadHook,
deleteHook,
getHookStatus,
uploadHook,
} from "../controllers/hookController";
import {
getPasswordEnabled
getPasswordEnabled,
resetPassword,
verifyPassword,
} from "../controllers/passwordController";
import {
deleteLegacyData,
formatFilenames,
getCloudflaredStatus,
getSettings,
migrateData,
updateSettings,
checkPasskeysExist,
generateAuthenticationOptions,
generateRegistrationOptions,
getPasskeys,
removeAllPasskeys,
verifyAuthentication,
verifyRegistration,
} from "../controllers/passkeyController";
import {
deleteLegacyData,
formatFilenames,
getCloudflaredStatus,
getSettings,
migrateData,
updateSettings,
} from "../controllers/settingsController";
import { asyncHandler } from "../middleware/errorHandler";
@@ -43,6 +54,17 @@ router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus));
// Password routes
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
router.post("/verify-password", asyncHandler(verifyPassword));
router.post("/reset-password", asyncHandler(resetPassword));
// Passkey routes
router.get("/passkeys", asyncHandler(getPasskeys));
router.get("/passkeys/exists", asyncHandler(checkPasskeysExist));
router.post("/passkeys/register", asyncHandler(generateRegistrationOptions));
router.post("/passkeys/register/verify", asyncHandler(verifyRegistration));
router.post("/passkeys/authenticate", asyncHandler(generateAuthenticationOptions));
router.post("/passkeys/authenticate/verify", asyncHandler(verifyAuthentication));
router.delete("/passkeys", asyncHandler(removeAllPasskeys));
// ... existing imports ...
@@ -56,11 +78,7 @@ router.post("/delete-cookies", asyncHandler(deleteCookies));
router.get("/check-cookies", asyncHandler(checkCookies));
// Hook routes
router.post(
"/hooks/:name",
upload.single("file"),
asyncHandler(uploadHook)
);
router.post("/hooks/:name", upload.single("file"), asyncHandler(uploadHook));
router.delete("/hooks/:name", asyncHandler(deleteHook));
router.get("/hooks/status", asyncHandler(getHookStatus));

View File

@@ -0,0 +1,279 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
generatePasskeyAuthenticationOptions,
generatePasskeyRegistrationOptions,
removeAllPasskeys,
verifyPasskeyAuthentication,
verifyPasskeyRegistration
} from "../passkeyService";
import * as storageService from "../storageService";
// Mock dependencies
vi.mock("../storageService", () => ({
getSettings: vi.fn(),
saveSettings: vi.fn(),
}));
vi.mock("@simplewebauthn/server", () => ({
generateRegistrationOptions: vi.fn(),
verifyRegistrationResponse: vi.fn(),
generateAuthenticationOptions: vi.fn(),
verifyAuthenticationResponse: vi.fn(),
}));
vi.mock("../../utils/logger", () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}));
describe("passkeyService", () => {
const mockPasskey = {
credentialID: "mock-credential-id",
credentialPublicKey: "mock-public-key",
counter: 0,
transports: ["internal"],
id: "mock-credential-id",
name: "Passkey 1",
createdAt: "2023-01-01T00:00:00.000Z",
rpID: "localhost",
origin: "http://localhost:5550",
};
beforeEach(() => {
vi.resetAllMocks();
(storageService.getSettings as any).mockReturnValue({});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("generatePasskeyRegistrationOptions", () => {
it("should generate registration options correctly", async () => {
const mockOptions = { challenge: "mock-challenge" };
(generateRegistrationOptions as any).mockResolvedValue(mockOptions);
const result = await generatePasskeyRegistrationOptions("testuser");
expect(generateRegistrationOptions).toHaveBeenCalledWith(
expect.objectContaining({
userName: "testuser",
attestationType: "none",
authenticatorSelection: expect.objectContaining({
authenticatorAttachment: "platform",
userVerification: "preferred",
}),
})
);
expect(result).toEqual({
options: mockOptions,
challenge: "mock-challenge",
});
});
it("should exclude existing credentials", async () => {
(storageService.getSettings as any).mockReturnValue({
passkeys: [mockPasskey],
});
(generateRegistrationOptions as any).mockResolvedValue({
challenge: "mock-challenge",
});
await generatePasskeyRegistrationOptions("testuser");
expect(generateRegistrationOptions).toHaveBeenCalledWith(
expect.objectContaining({
excludeCredentials: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String), // In the real code it's base64url encoded
}),
]),
})
);
});
});
describe("verifyPasskeyRegistration", () => {
it("should verify and store a new passkey correctly (NO double encoding)", async () => {
const mockVerification = {
verified: true,
registrationInfo: {
credential: {
id: "raw-credential-id-from-browser", // Assume simplewebauthn returns this as string/base64url
publicKey: Buffer.from("mock-public-key"),
counter: 0,
transports: ["internal"],
},
},
};
(verifyRegistrationResponse as any).mockResolvedValue(mockVerification);
const result = await verifyPasskeyRegistration(
{ response: {}, name: "My Passkey" },
"mock-challenge"
);
expect(result.verified).toBe(true);
expect(result.passkey?.credentialID).toBe("raw-credential-id-from-browser"); // MUST NOT BE DOUBLE ENCODED
expect(storageService.saveSettings).toHaveBeenCalledWith(
expect.objectContaining({
passkeys: expect.arrayContaining([
expect.objectContaining({
credentialID: "raw-credential-id-from-browser",
name: "My Passkey",
}),
]),
})
);
});
it("should handle verification failure", async () => {
(verifyRegistrationResponse as any).mockResolvedValue({ verified: false });
const result = await verifyPasskeyRegistration({}, "mock-challenge");
expect(result.verified).toBe(false);
expect(storageService.saveSettings).not.toHaveBeenCalled();
});
});
describe("generatePasskeyAuthenticationOptions", () => {
it("should generate authentication options with correct allowCredentials (NO double encoding)", async () => {
(storageService.getSettings as any).mockReturnValue({
passkeys: [mockPasskey],
});
(generateAuthenticationOptions as any).mockResolvedValue({
challenge: "mock-challenge",
});
const result = await generatePasskeyAuthenticationOptions("localhost");
expect(generateAuthenticationOptions).toHaveBeenCalledWith(
expect.objectContaining({
allowCredentials: expect.arrayContaining([
expect.objectContaining({
id: "mock-credential-id", // MUST MATCH STORED ID EXACTLY
transports: ["internal"],
}),
]),
})
);
expect(result).toEqual({
options: { challenge: "mock-challenge" },
challenge: "mock-challenge",
});
});
it("should filter passkeys by RP ID", async () => {
const passkey1 = { ...mockPasskey, rpID: "domain1.com", id: "id1", credentialID: "id1" };
const passkey2 = { ...mockPasskey, rpID: "domain2.com", id: "id2", credentialID: "id2" };
(storageService.getSettings as any).mockReturnValue({
passkeys: [passkey1, passkey2],
});
(generateAuthenticationOptions as any).mockResolvedValue({
challenge: "mock-challenge",
});
await generatePasskeyAuthenticationOptions("domain1.com");
expect(generateAuthenticationOptions).toHaveBeenCalledWith(
expect.objectContaining({
allowCredentials: [
expect.objectContaining({ id: "id1" })
]
})
);
});
it("should include legacy passkeys (no rpID stored) as fallback", async () => {
const legacyPasskey = { ...mockPasskey, rpID: undefined, id: "legacy", credentialID: "legacy" };
(storageService.getSettings as any).mockReturnValue({
passkeys: [legacyPasskey],
});
(generateAuthenticationOptions as any).mockResolvedValue({
challenge: "mock-challenge",
});
await generatePasskeyAuthenticationOptions("any-domain.com");
expect(generateAuthenticationOptions).toHaveBeenCalledWith(
expect.objectContaining({
allowCredentials: [
expect.objectContaining({ id: "legacy" })
]
})
);
});
it("should throw if no passkeys registered", async () => {
(storageService.getSettings as any).mockReturnValue({});
await expect(generatePasskeyAuthenticationOptions()).rejects.toThrow(
"No passkeys registered"
);
});
});
describe("verifyPasskeyAuthentication", () => {
it("should verify authentication successfully", async () => {
(storageService.getSettings as any).mockReturnValue({
passkeys: [mockPasskey],
});
const mockVerification = {
verified: true,
authenticationInfo: { newCounter: 1 },
};
(verifyAuthenticationResponse as any).mockResolvedValue(mockVerification);
const result = await verifyPasskeyAuthentication(
{ id: "mock-credential-id", response: {} },
"mock-challenge"
);
expect(result.verified).toBe(true);
expect(storageService.saveSettings).toHaveBeenCalledWith(
expect.objectContaining({
passkeys: expect.arrayContaining([
expect.objectContaining({
credentialID: "mock-credential-id",
counter: 1
})
])
})
);
});
it("should fail if passkey not found", async () => {
(storageService.getSettings as any).mockReturnValue({
passkeys: [mockPasskey],
});
const result = await verifyPasskeyAuthentication(
{ id: "unknown-id", response: {} },
"mock-challenge"
);
expect(result.verified).toBe(false);
expect(verifyAuthenticationResponse).not.toHaveBeenCalled();
});
});
describe("removeAllPasskeys", () => {
it("should remove all passkeys", () => {
removeAllPasskeys();
expect(storageService.saveSettings).toHaveBeenCalledWith({
passkeys: []
});
});
});
});

View File

@@ -37,6 +37,7 @@ export function prepareDownloadFlags(
}
// Prepare base flags from user config (excluding output options we manage)
// Explicitly preserve network-related options like proxy
const {
output: _output, // Ignore user output template (we manage this)
o: _o,
@@ -50,9 +51,18 @@ export function prepareDownloadFlags(
convertSubs: userConvertSubs,
// Extract user merge output format (use it if provided)
mergeOutputFormat: userMergeOutputFormat,
proxy: _proxy, // Proxy is handled separately in networkOptions to ensure it's preserved
...safeUserConfig
} = config;
// Explicitly preserve proxy and other network options to ensure they're not lost
// This is critical for download operations that need proxy settings
const networkOptions: Record<string, any> = {};
if (config.proxy) {
networkOptions.proxy = config.proxy;
logger.debug("Preserving proxy in networkOptions:", config.proxy);
}
// Get format sort option if user specified it
const formatSortValue = userFormatSort || userFormatSort2;
@@ -73,8 +83,10 @@ export function prepareDownloadFlags(
const mergeOutputFormat = userMergeOutputFormat || defaultMergeFormat;
// Prepare flags - defaults first, then user config to allow overrides
// Network options (like proxy) are applied last to ensure they're not overridden
const flags: YtDlpFlags = {
...safeUserConfig, // Apply user config
...networkOptions, // Explicitly apply network options (proxy, etc.) to ensure they're preserved
output: outputPath, // Always use our output path with correct extension
format: defaultFormat,
// Use user preferences if provided, otherwise use defaults
@@ -99,7 +111,9 @@ export function prepareDownloadFlags(
"bestvideo[ext=mp4][vcodec^=avc1]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best";
}
// Ensure merge output format is mp4 (already handled above, but log it)
logger.info("Twitter/X URL detected - using MP4 format for Safari compatibility");
logger.info(
"Twitter/X URL detected - using MP4 format for Safari compatibility"
);
}
// Add YouTube specific flags if it's a YouTube URL
@@ -153,6 +167,16 @@ export function prepareDownloadFlags(
delete flags.extractorArgs;
}
// Log proxy in final flags for debugging
if (flags.proxy) {
logger.debug("Proxy in final flags:", flags.proxy);
} else if (config.proxy) {
logger.warn(
"Proxy was in config but not in final flags. Config proxy:",
config.proxy
);
}
logger.debug("Final yt-dlp flags:", flags);
return {

View File

@@ -11,6 +11,7 @@ import { ProgressTracker } from "../../../utils/progressTracker";
import {
executeYtDlpJson,
executeYtDlpSpawn,
getNetworkConfigFromUserConfig,
getUserYtDlpConfig,
} from "../../../utils/ytDlpUtils";
import * as storageService from "../../storageService";
@@ -86,8 +87,13 @@ export async function downloadVideo(
try {
const PROVIDER_SCRIPT = getProviderScript();
// Get user's yt-dlp configuration for network options (including proxy)
const userConfig = getUserYtDlpConfig(videoUrl);
const networkConfig = getNetworkConfigFromUserConfig(userConfig);
// Get video info first
const info = await executeYtDlpJson(videoUrl, {
...networkConfig,
noWarnings: true,
preferFreeFormats: true,
...(PROVIDER_SCRIPT
@@ -142,16 +148,32 @@ export async function downloadVideo(
});
}
// Get user's yt-dlp configuration
const userConfig = getUserYtDlpConfig(videoUrl);
// Get user's yt-dlp configuration (reuse from above if available, otherwise fetch again)
// Note: userConfig was already fetched above, but we need to ensure it's still valid
const downloadUserConfig = userConfig || getUserYtDlpConfig(videoUrl);
// Log proxy configuration for debugging
if (downloadUserConfig.proxy) {
logger.info("Using proxy for download:", downloadUserConfig.proxy);
}
// Prepare download flags
const { flags, mergeOutputFormat } = prepareDownloadFlags(
videoUrl,
newVideoPath,
userConfig
downloadUserConfig
);
// Log final flags to verify proxy is included
if (flags.proxy) {
logger.info("Proxy included in download flags:", flags.proxy);
} else {
logger.warn(
"Proxy not found in download flags. User config proxy:",
downloadUserConfig.proxy
);
}
// Update the video path to use the correct extension based on merge format
const videoExtension = mergeOutputFormat;
const newVideoPathWithFormat = newVideoPath.replace(

View File

@@ -0,0 +1,334 @@
import type {
GenerateAuthenticationOptionsOpts,
GenerateRegistrationOptionsOpts,
VerifyAuthenticationResponseOpts,
VerifyRegistrationResponseOpts,
} from "@simplewebauthn/server";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { logger } from "../utils/logger";
import * as storageService from "./storageService";
// RP (Relying Party) configuration
const rpName = "MyTube";
const rpID = process.env.RP_ID || "localhost"; // Default to localhost for development
export const defaultOrigin = process.env.ORIGIN || `http://${rpID}:5550`; // Frontend origin
const origin = defaultOrigin;
// Storage key for passkeys
const PASSKEYS_STORAGE_KEY = "passkeys";
interface StoredPasskey {
credentialID: string; // Base64url encoded
credentialPublicKey: string; // Base64 encoded
counter: number;
transports?: string[];
id: string; // Same as credentialID for convenience
name?: string;
createdAt: string;
rpID?: string; // Store the RP_ID used during registration for debugging
origin?: string; // Store the origin used during registration for debugging
}
/**
* Get all stored passkeys
*/
export function getPasskeys(): StoredPasskey[] {
try {
const settings = storageService.getSettings();
const passkeys = settings[PASSKEYS_STORAGE_KEY];
if (!passkeys || !Array.isArray(passkeys)) {
return [];
}
return passkeys;
} catch (error) {
logger.error(
"Error getting passkeys",
error instanceof Error ? error : new Error(String(error))
);
return [];
}
}
/**
* Save passkeys to storage
*/
function savePasskeys(passkeys: StoredPasskey[]): void {
try {
storageService.saveSettings({
[PASSKEYS_STORAGE_KEY]: passkeys,
});
} catch (error) {
logger.error(
"Error saving passkeys",
error instanceof Error ? error : new Error(String(error))
);
throw error;
}
}
/**
* Generate registration options for creating a new passkey
*/
export async function generatePasskeyRegistrationOptions(
userName: string = "MyTube User",
originOverride?: string,
rpIDOverride?: string
): Promise<{
options: any;
challenge: string;
}> {
const existingPasskeys = getPasskeys();
const effectiveRPID = rpIDOverride || rpID;
const opts: GenerateRegistrationOptionsOpts = {
rpName,
rpID: effectiveRPID,
userID: Buffer.from(userName),
userName,
timeout: 60000,
attestationType: "none",
excludeCredentials: existingPasskeys.map((passkey) => ({
id: Buffer.from(passkey.credentialID, "base64url").toString("base64url"),
type: "public-key" as const,
transports: passkey.transports as any,
})),
authenticatorSelection: {
authenticatorAttachment: "platform",
userVerification: "preferred",
requireResidentKey: false,
},
supportedAlgorithmIDs: [-7, -257], // ES256 and RS256
};
const options = await generateRegistrationOptions(opts);
// Store challenge temporarily (in a real app, you'd use a session or cache)
// For simplicity, we'll return it and the frontend will send it back
return {
options,
challenge: options.challenge,
};
}
/**
* Verify and store a new passkey
*/
export async function verifyPasskeyRegistration(
body: any,
challenge: string,
originOverride?: string,
rpIDOverride?: string
): Promise<{ verified: boolean; passkey?: StoredPasskey }> {
try {
const existingPasskeys = getPasskeys();
const effectiveRPID = rpIDOverride || rpID;
const effectiveOrigin = originOverride || origin;
logger.info(
`Verifying passkey registration with RP_ID: ${effectiveRPID}, Origin: ${effectiveOrigin}`
);
const opts: VerifyRegistrationResponseOpts = {
response: body,
expectedChallenge: challenge,
expectedOrigin: effectiveOrigin,
expectedRPID: effectiveRPID,
requireUserVerification: false,
};
const verification = await verifyRegistrationResponse(opts);
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
const credentialID = credential.id;
const credentialPublicKey = Buffer.from(credential.publicKey).toString(
"base64"
);
const newPasskey: StoredPasskey = {
credentialID,
credentialPublicKey,
counter: credential.counter || 0,
transports: body.response.transports || credential.transports || [],
id: credentialID,
name: body.name || `Passkey ${existingPasskeys.length + 1}`,
createdAt: new Date().toISOString(),
rpID: effectiveRPID, // Store RP_ID for debugging
origin: effectiveOrigin, // Store origin for debugging
};
logger.info(
`Passkey registered successfully with RP_ID: ${effectiveRPID}, Origin: ${effectiveOrigin}`
);
const updatedPasskeys = [...existingPasskeys, newPasskey];
savePasskeys(updatedPasskeys);
logger.info("New passkey registered successfully");
return { verified: true, passkey: newPasskey };
}
return { verified: false };
} catch (error) {
logger.error(
"Error verifying passkey registration",
error instanceof Error ? error : new Error(String(error))
);
return { verified: false };
}
}
/**
* Generate authentication options for passkey login
*/
export async function generatePasskeyAuthenticationOptions(
rpIDOverride?: string
): Promise<{
options: any;
challenge: string;
}> {
const passkeys = getPasskeys();
if (passkeys.length === 0) {
throw new Error("No passkeys registered");
}
const effectiveRPID = rpIDOverride || rpID;
logger.info(
`Generating authentication options with RP_ID: ${effectiveRPID}, Found ${passkeys.length} passkey(s)`
);
// Log stored RP_IDs for debugging
const storedRPIDs = passkeys.map((p) => p.rpID || "not set");
logger.info(`Stored passkeys RP_IDs: ${storedRPIDs.join(", ")}`);
// Filter passkeys to only include those that match the current RP_ID
// This is critical - browsers will only find passkeys that match the RP_ID
const matchingPasskeys = passkeys.filter((passkey) => {
// If passkey has stored RP_ID, it must match
if (passkey.rpID) {
return passkey.rpID === effectiveRPID;
}
// For passkeys without stored RP_ID (legacy data), include them as fallback
// This allows old passkeys to still work
return true;
});
logger.info(
`Using ${matchingPasskeys.length} passkey(s) matching RP_ID: ${effectiveRPID}`
);
if (matchingPasskeys.length === 0) {
throw new Error(
`No passkeys found for RP_ID: ${effectiveRPID}. Please create a new passkey.`
);
}
// Since we only allow platform authenticators during registration (authenticatorAttachment: "platform"),
// all passkeys should be platform authenticators. Explicitly set transports to ["internal"]
// to ensure the browser uses the platform authenticator (fingerprint/face ID) instead of
// falling back to cross-platform authentication (QR code)
const opts: GenerateAuthenticationOptionsOpts = {
timeout: 60000,
allowCredentials: matchingPasskeys.map((passkey) => ({
id: passkey.credentialID,
type: "public-key" as const,
// Always specify "internal" transport since we only register platform authenticators
// This tells the browser to use the device's built-in authenticator (fingerprint/face ID)
transports: ["internal"] as any,
})),
userVerification: "preferred",
rpID: effectiveRPID,
};
const options = await generateAuthenticationOptions(opts);
return {
options,
challenge: options.challenge,
};
}
/**
* Verify passkey authentication
*/
export async function verifyPasskeyAuthentication(
body: any,
challenge: string,
originOverride?: string,
rpIDOverride?: string
): Promise<{ verified: boolean }> {
try {
const passkeys = getPasskeys();
// Find passkey by matching the credential ID
// body.id is already in base64url format from the browser
const passkey = passkeys.find((p) => p.credentialID === body.id);
if (!passkey) {
logger.warn("Passkey not found for authentication");
return { verified: false };
}
const effectiveRPID = rpIDOverride || rpID;
const effectiveOrigin = originOverride || origin;
const opts: VerifyAuthenticationResponseOpts = {
response: body,
expectedChallenge: challenge,
expectedOrigin: effectiveOrigin,
expectedRPID: effectiveRPID,
credential: {
id: passkey.credentialID,
publicKey: Buffer.from(passkey.credentialPublicKey, "base64") as any,
counter: passkey.counter,
transports: passkey.transports as any,
},
requireUserVerification: false,
};
const verification = await verifyAuthenticationResponse(opts);
if (verification.verified) {
// Update counter
const updatedPasskeys = passkeys.map((p) =>
p.credentialID === passkey.credentialID
? { ...p, counter: verification.authenticationInfo.newCounter }
: p
);
savePasskeys(updatedPasskeys);
logger.info("Passkey authentication successful");
return { verified: true };
}
return { verified: false };
} catch (error) {
logger.error(
"Error verifying passkey authentication",
error instanceof Error ? error : new Error(String(error))
);
return { verified: false };
}
}
/**
* Remove all passkeys
*/
export function removeAllPasskeys(): void {
try {
savePasskeys([]);
logger.info("All passkeys removed");
} catch (error) {
logger.error(
"Error removing passkeys",
error instanceof Error ? error : new Error(String(error))
);
throw error;
}
}

View File

@@ -5,18 +5,31 @@ import * as storageService from "./storageService";
import { logger } from "../utils/logger";
import { Settings, defaultSettings } from "../types/settings";
/**
* Check if login is required (loginEnabled is true)
*/
export function isLoginRequired(): boolean {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
return mergedSettings.loginEnabled === true;
}
/**
* Check if password authentication is enabled
*/
export function isPasswordEnabled(): {
enabled: boolean;
waitTime?: number;
loginRequired?: boolean;
} {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Return true only if login is enabled AND a password is set
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password;
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
// Return true only if login is enabled AND a password is set AND password login is allowed
const isEnabled = mergedSettings.loginEnabled && !!mergedSettings.password && passwordLoginAllowed;
// Check for remaining wait time
const remainingWaitTime = loginAttemptService.canAttemptLogin();
@@ -24,6 +37,7 @@ export function isPasswordEnabled(): {
return {
enabled: isEnabled,
waitTime: remainingWaitTime > 0 ? remainingWaitTime : undefined,
loginRequired: mergedSettings.loginEnabled === true,
};
}
@@ -41,6 +55,16 @@ export async function verifyPassword(
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
if (!passwordLoginAllowed) {
return {
success: false,
message: "Password login is not allowed. Please use passkey authentication.",
};
}
if (!mergedSettings.password) {
// If no password set but login enabled, allow access
return { success: true };
@@ -90,6 +114,23 @@ export async function hashPassword(password: string): Promise<string> {
* Returns the new password (should be logged, not sent to frontend)
*/
export async function resetPassword(): Promise<string> {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Check if password reset is allowed (defaults to true for backward compatibility)
const allowResetPassword = mergedSettings.allowResetPassword !== false;
if (!allowResetPassword) {
throw new Error("Password reset is not allowed. The allowResetPassword setting is disabled.");
}
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
if (!passwordLoginAllowed) {
throw new Error("Password reset is not allowed when password login is disabled");
}
// Generate random 8-character password using cryptographically secure random
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
@@ -102,8 +143,6 @@ export async function resetPassword(): Promise<string> {
const hashedPassword = await hashPassword(newPassword);
// Update settings with new password
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
mergedSettings.password = hashedPassword;
mergedSettings.loginEnabled = true; // Ensure login is enabled

View File

@@ -137,9 +137,19 @@ export async function prepareSettingsForSave(
const prepared = { ...newSettings };
// Handle password hashing
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = existingSettings.passwordLoginAllowed !== false;
if (prepared.password) {
// If password is provided, hash it
prepared.password = await hashPassword(prepared.password);
// If password login is not allowed, reject password updates
if (!passwordLoginAllowed) {
// Remove password from prepared settings to prevent update
delete prepared.password;
logger.warn("Password update rejected: password login is not allowed");
} else {
// If password is provided and allowed, hash it
prepared.password = await hashPassword(prepared.password);
}
} else {
// If password is empty/not provided, keep existing password
prepared.password = existingSettings.password;

View File

@@ -1,6 +1,8 @@
export interface Settings {
loginEnabled: boolean;
password?: string;
passwordLoginAllowed?: boolean;
allowResetPassword?: boolean;
defaultAutoPlay: boolean;
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;

View File

@@ -58,8 +58,12 @@ export function validatePathWithinDirectory(
// Reconstruct paths from validated components only
// This ensures no path traversal sequences can exist
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
const sanitizedFilePath = path.isAbsolute(filePath)
? path.sep + path.join(...sanitizedFilePathParts)
: path.join(...sanitizedFilePathParts);
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
? path.sep + path.join(...sanitizedAllowedDirParts)
: path.join(...sanitizedAllowedDirParts);
// Final validation: ensure reconstructed paths don't contain traversal sequences
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {
@@ -130,8 +134,12 @@ export function resolveSafePath(filePath: string, allowedDir: string): string {
// Reconstruct paths from validated components only
// This ensures no path traversal sequences can exist
const sanitizedFilePath = path.join(...sanitizedFilePathParts);
const sanitizedAllowedDir = path.join(...sanitizedAllowedDirParts);
const sanitizedFilePath = path.isAbsolute(filePath)
? path.sep + path.join(...sanitizedFilePathParts)
: path.join(...sanitizedFilePathParts);
const sanitizedAllowedDir = path.isAbsolute(allowedDir)
? path.sep + path.join(...sanitizedAllowedDirParts)
: path.join(...sanitizedAllowedDirParts);
// Final validation: ensure reconstructed paths don't contain traversal sequences
if (sanitizedFilePath.includes("..") || sanitizedAllowedDir.includes("..")) {

View File

@@ -1,17 +1,18 @@
{
"name": "frontend",
"version": "1.7.21",
"version": "1.7.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.7.21",
"version": "1.7.26",
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query-devtools": "^5.91.1",
"axios": "^1.13.2",
@@ -1918,6 +1919,12 @@
"win32"
]
},
"node_modules/@simplewebauthn/browser": {
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz",
"integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.7.21",
"version": "1.7.26",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,6 +16,7 @@
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/react-query": "^5.90.11",
"@tanstack/react-query-devtools": "^5.91.1",
"axios": "^1.13.2",

View File

@@ -41,12 +41,11 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
display: 'flex',
flexDirection: 'column',
position: 'relative',
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s, border-color 0.3s',
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: theme.shadows[8],
},
border: `1px solid ${theme.palette.secondary.main}`
}
}}
>
<CardActionArea onClick={handleClick} sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
@@ -76,7 +75,7 @@ const CollectionCard: React.FC<CollectionCardProps> = ({ collection, videos }) =
<Chip
icon={<Folder />}
label={`${collection.videos.length} videos`}
label={collection.videos.length}
color="secondary"
size="small"
sx={{ position: 'absolute', bottom: 8, right: 8 }}

View File

@@ -1,13 +1,17 @@
import { Help, Settings, VideoLibrary } from '@mui/icons-material';
import { Help, Logout, Settings, VideoLibrary } from '@mui/icons-material';
import {
alpha,
Divider,
Fade,
Menu,
MenuItem,
useMediaQuery,
useTheme
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../contexts/LanguageContext';
interface ManageMenuProps {
@@ -21,8 +25,33 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
}) => {
const navigate = useNavigate();
const { t } = useLanguage();
const { logout } = useAuth();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const API_URL = import.meta.env.VITE_API_URL;
// Check if login is enabled
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
try {
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
return response.data;
} catch (error) {
return null;
}
},
retry: 1,
retryDelay: 1000,
});
const loginEnabled = settingsData?.loginEnabled || false;
const handleLogout = () => {
onClose();
logout();
navigate('/');
};
return (
<Menu
@@ -68,6 +97,12 @@ const ManageMenu: React.FC<ManageMenuProps> = ({
<MenuItem onClick={() => { onClose(); navigate('/instruction'); }}>
<Help sx={{ mr: 2 }} /> {t('instruction')}
</MenuItem>
{loginEnabled && <Divider />}
{loginEnabled && (
<MenuItem onClick={handleLogout}>
<Logout sx={{ mr: 2 }} /> {t('logout')}
</MenuItem>
)}
</Menu>
);
};

View File

@@ -1,6 +1,9 @@
import { Settings, VideoLibrary } from '@mui/icons-material';
import { Box, Button, Collapse, Stack } from '@mui/material';
import { Link } from 'react-router-dom';
import { Logout, Settings, VideoLibrary } from '@mui/icons-material';
import { Box, Button, Collapse, Divider, Stack } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../contexts/LanguageContext';
import { useVisitorMode } from '../../contexts/VisitorModeContext';
import { Collection, Video } from '../../types';
@@ -45,7 +48,33 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
onTagToggle
}) => {
const { t } = useLanguage();
const { logout } = useAuth();
const { visitorMode } = useVisitorMode();
const navigate = useNavigate();
const API_URL = import.meta.env.VITE_API_URL;
// Check if login is enabled
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
try {
const response = await axios.get(`${API_URL}/settings`, { timeout: 5000 });
return response.data;
} catch (error) {
return null;
}
},
retry: 1,
retryDelay: 1000,
});
const loginEnabled = settingsData?.loginEnabled || false;
const handleLogout = () => {
onClose();
logout();
navigate('/');
};
return (
<Collapse in={open} sx={{ width: '100%' }}>
@@ -91,6 +120,22 @@ const MobileMenu: React.FC<MobileMenuProps> = ({
</Button>
</Box>
{/* Logout Button */}
{loginEnabled && (
<>
<Divider />
<Button
variant="outlined"
color="error"
fullWidth
onClick={handleLogout}
startIcon={<Logout />}
>
{t('logout')}
</Button>
</>
)}
{/* Mobile Navigation Items */}
<Box sx={{ mt: 2 }}>
<Collections

View File

@@ -6,6 +6,7 @@ import React, { useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { Settings } from '../../types';
import ConfirmationModal from '../ConfirmationModal';
import PasswordModal from '../PasswordModal';
interface HookSettingsProps {
settings: Settings;
@@ -17,6 +18,11 @@ const API_URL = import.meta.env.VITE_API_URL;
const HookSettings: React.FC<HookSettingsProps> = () => {
const { t } = useLanguage();
const [deleteHookName, setDeleteHookName] = useState<string | null>(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [passwordError, setPasswordError] = useState<string | undefined>(undefined);
const [isVerifying, setIsVerifying] = useState(false);
const [pendingUpload, setPendingUpload] = useState<{ hookName: string; file: File } | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const { data: hookStatus, refetch: refetchHooks, isLoading } = useQuery({
queryKey: ['hookStatus'],
@@ -36,6 +42,20 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
},
onSuccess: () => {
refetchHooks();
setPendingUpload(null);
setUploadError(null);
},
onError: (error: any) => {
console.error('Upload failed:', error);
const message = error.response?.data?.message || error.message;
// Try to match risk command error
// Backend sends: "Risk command detected: {command}. Upload rejected."
const riskMatch = message?.match(/Risk command detected: (.*)\. Upload rejected\./);
if (riskMatch && riskMatch[1]) {
setUploadError(t('riskCommandDetected', { command: riskMatch[1] }));
} else {
setUploadError(message || t('uploadFailed'));
}
}
});
@@ -58,10 +78,35 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
return;
}
uploadMutation.mutate({ hookName, file });
// Reset input so the same file can be selected again
e.target.value = '';
setPendingUpload({ hookName, file });
setPasswordError(undefined);
setUploadError(null);
setShowPasswordModal(true);
};
const handlePasswordConfirm = async (password: string) => {
setIsVerifying(true);
setPasswordError(undefined);
try {
await axios.post(`${API_URL}/settings/verify-password`, { password });
setShowPasswordModal(false);
if (pendingUpload) {
uploadMutation.mutate(pendingUpload);
}
} catch (error: any) {
console.error('Password verification failed:', error);
if (error.response?.status === 429) {
const waitTime = error.response.data.waitTime;
setPasswordError(t('tooManyAttempts') + ` Try again in ${Math.ceil(waitTime / 1000)}s`);
} else {
setPasswordError(t('incorrectPassword'));
}
} finally {
setIsVerifying(false);
}
};
const handleDelete = (hookName: string) => {
@@ -109,6 +154,12 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
{t('taskHooksWarning')}
</Alert>
{uploadError && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setUploadError(null)}>
{uploadError}
</Alert>
)}
{isLoading ? (
<CircularProgress />
) : (
@@ -185,6 +236,20 @@ const HookSettings: React.FC<HookSettingsProps> = () => {
cancelText={t('cancel') || 'Cancel'}
isDanger={true}
/>
<PasswordModal
isOpen={showPasswordModal}
onClose={() => {
setShowPasswordModal(false);
setPendingUpload(null);
setPasswordError(undefined);
}}
onConfirm={handlePasswordConfirm}
title={t('enterPassword')}
message={t('enterPasswordToUploadHook') || 'Please enter your password to upload this hook script.'}
error={passwordError}
isLoading={isVerifying}
/>
</Box>
);
};

View File

@@ -1,7 +1,14 @@
import { Box, FormControlLabel, Switch, TextField } from '@mui/material';
import React from 'react';
import { Box, Button, FormControlLabel, Switch, TextField, Typography } from '@mui/material';
import { startRegistration } from '@simplewebauthn/browser';
import { useMutation, useQuery } from '@tanstack/react-query';
import axios from 'axios';
import React, { useState } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { Settings } from '../../types';
import AlertModal from '../AlertModal';
import ConfirmationModal from '../ConfirmationModal';
const API_URL = import.meta.env.VITE_API_URL;
interface SecuritySettingsProps {
settings: Settings;
@@ -10,6 +17,120 @@ interface SecuritySettingsProps {
const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange }) => {
const { t } = useLanguage();
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [alertOpen, setAlertOpen] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const showAlert = (title: string, message: string) => {
setAlertTitle(title);
setAlertMessage(message);
setAlertOpen(true);
};
// Check if passkeys exist
const { data: passkeysData, refetch: refetchPasskeys } = useQuery({
queryKey: ['passkeys-exists'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings/passkeys/exists`);
return response.data;
},
});
const passkeysExist = passkeysData?.exists || false;
// Create passkey mutation
const createPasskeyMutation = useMutation({
mutationFn: async () => {
// Step 1: Get registration options
const optionsResponse = await axios.post(`${API_URL}/settings/passkeys/register`, {
userName: 'MyTube User',
});
const { options, challenge } = optionsResponse.data;
// Step 2: Start registration with browser
const attestationResponse = await startRegistration(options);
// Step 3: Verify registration
const verifyResponse = await axios.post(`${API_URL}/settings/passkeys/register/verify`, {
body: attestationResponse,
challenge,
});
if (!verifyResponse.data.success) {
throw new Error('Passkey registration failed');
}
},
onSuccess: () => {
refetchPasskeys();
refetchPasskeys();
showAlert(t('success'), t('passkeyCreated') || 'Passkey created successfully');
},
onError: (error: any) => {
console.error('Error creating passkey:', error);
// Extract error message from axios response or error object
let errorMessage = t('passkeyCreationFailed') || 'Failed to create passkey. Please try again.';
if (error?.response?.data?.error) {
// Backend error message
errorMessage = error.response.data.error;
} else if (error?.response?.data?.message) {
errorMessage = error.response.data.message;
} else if (error?.message) {
errorMessage = error.message;
}
showAlert(t('error'), errorMessage);
},
});
// Remove passkeys mutation
const removePasskeysMutation = useMutation({
mutationFn: async () => {
await axios.delete(`${API_URL}/settings/passkeys`);
},
onSuccess: () => {
refetchPasskeys();
setShowRemoveModal(false);
showAlert(t('success'), t('passkeysRemoved') || 'All passkeys have been removed');
},
onError: (error: any) => {
console.error('Error removing passkeys:', error);
showAlert(t('error'), t('passkeysRemoveFailed') || 'Failed to remove passkeys. Please try again.');
},
});
const handleCreatePasskey = () => {
// Check if we're in a secure context (HTTPS or localhost)
// This is the most important check - WebAuthn requires secure context
if (!window.isSecureContext) {
const hostname = window.location.hostname;
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
if (!isLocalhost) {
showAlert(t('error'), t('passkeyRequiresHttps') || 'WebAuthn requires HTTPS or localhost. Please access the application via HTTPS or use localhost instead of an IP address.');
return;
}
}
// Check if WebAuthn is supported
// Check multiple ways to detect WebAuthn support
const hasWebAuthn =
typeof window.PublicKeyCredential !== 'undefined' ||
(typeof navigator !== 'undefined' && 'credentials' in navigator && 'create' in navigator.credentials);
if (!hasWebAuthn) {
showAlert(t('error'), t('passkeyWebAuthnNotSupported') || 'WebAuthn is not supported in this browser. Please use a modern browser that supports WebAuthn.');
return;
}
createPasskeyMutation.mutate();
};
const handleRemovePasskeys = () => {
removePasskeysMutation.mutate();
};
return (
<Box>
@@ -25,20 +146,98 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
{settings.loginEnabled && (
<Box sx={{ mt: 2, maxWidth: 400 }}>
<TextField
fullWidth
label={t('password')}
type="password"
value={settings.password || ''}
onChange={(e) => onChange('password', e.target.value)}
helperText={
settings.isPasswordSet
? t('passwordHelper')
: t('passwordSetHelper')
{settings.passwordLoginAllowed !== false && (
<TextField
fullWidth
sx={{ mb: 2 }}
label={t('password')}
type="password"
value={settings.password || ''}
onChange={(e) => onChange('password', e.target.value)}
helperText={
settings.isPasswordSet
? t('passwordHelper')
: t('passwordSetHelper')
}
/>
)}
<FormControlLabel
control={
<Switch
checked={settings.passwordLoginAllowed !== false}
onChange={(e) => onChange('passwordLoginAllowed', e.target.checked)}
disabled={!settings.loginEnabled || !passkeysExist}
/>
}
label={t('allowPasswordLogin') || 'Allow Password Login'}
/>
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('allowPasswordLoginHelper') || 'When disabled, password login is not available. You must have at least one passkey to disable password login.'}
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.allowResetPassword !== false}
onChange={(e) => onChange('allowResetPassword', e.target.checked)}
disabled={!settings.loginEnabled}
/>
}
label={t('allowResetPassword') || 'Allow Reset Password'}
/>
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('allowResetPasswordHelper') || 'When disabled, the reset password button will not be shown on the login page and the reset password API will be blocked.'}
</Typography>
</Box>
<Box sx={{ mt: 3 }}>
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
onClick={handleCreatePasskey}
disabled={!settings.loginEnabled || createPasskeyMutation.isPending}
fullWidth
>
{createPasskeyMutation.isPending
? (t('creatingPasskey') || 'Creating...')
: (t('createPasskey') || 'Create Passkey')}
</Button>
</Box>
<Button
variant="outlined"
color="error"
onClick={() => setShowRemoveModal(true)}
disabled={!settings.loginEnabled || !passkeysExist || removePasskeysMutation.isPending}
fullWidth
>
{t('removePasskeys') || 'Remove All Passkeys'}
</Button>
</Box>
</Box>
)}
<ConfirmationModal
isOpen={showRemoveModal}
onClose={() => setShowRemoveModal(false)}
onConfirm={handleRemovePasskeys}
title={t('removePasskeysTitle') || 'Remove All Passkeys'}
message={t('removePasskeysMessage') || 'Are you sure you want to remove all passkeys? This action cannot be undone.'}
confirmText={t('remove') || 'Remove'}
cancelText={t('cancel') || 'Cancel'}
isDanger={true}
/>
<AlertModal
open={alertOpen}
onClose={() => setAlertOpen(false)}
title={alertTitle}
message={alertMessage}
/>
</Box>
);
};

View File

@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render as rtlRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import SecuritySettings from '../SecuritySettings';
@@ -8,6 +9,23 @@ vi.mock('../../../contexts/LanguageContext', () => ({
useLanguage: () => ({ t: (key: string) => key }),
}));
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const render = (ui: React.ReactElement) => {
const queryClient = createTestQueryClient();
return rtlRender(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
};
describe('SecuritySettings', () => {
const mockOnChange = vi.fn();
const defaultSettings: any = {

View File

@@ -2,7 +2,7 @@ import { Box, CardContent, Typography } from '@mui/material';
import React from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import { Video } from '../../types';
import { formatDate } from '../../utils/formatUtils';
import { formatRelativeDownloadTime } from '../../utils/formatUtils';
import { VideoCardCollectionInfo } from '../../utils/videoCardUtils';
interface VideoCardContentProps {
@@ -72,7 +72,7 @@ export const VideoCardContent: React.FC<VideoCardContentProps> = ({
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
<Typography variant="caption" color="text.secondary">
{formatDate(video.date)}
{formatRelativeDownloadTime(video.addedAt, video.date, t)}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
{video.viewCount || 0} {t('views')}

View File

@@ -10,6 +10,7 @@ vi.mock('../../../contexts/LanguageContext', () => ({
vi.mock('../../../utils/formatUtils', () => ({
formatDate: () => '2023-01-01',
formatRelativeDownloadTime: () => '2023-01-01',
}));

View File

@@ -86,7 +86,7 @@ describe('CollectionCard', () => {
);
expect(screen.getByText(/Test Collection/i)).toBeInTheDocument();
expect(screen.getByText(/2 videos/i)).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
});
it('renders collection creation date', () => {
@@ -131,7 +131,7 @@ describe('CollectionCard', () => {
);
// Should show folder icon (via Material-UI icon)
expect(screen.getByText(/0 videos/i)).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
});
it('displays up to 4 thumbnails in grid', () => {

View File

@@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthProvider } from '../../contexts/AuthContext';
import Header from '../Header';
// Mock contexts
@@ -92,11 +93,13 @@ describe('Header', () => {
});
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<BrowserRouter>
<Header {...defaultProps} {...props} />
</BrowserRouter>
</ThemeProvider>
<AuthProvider>
<ThemeProvider theme={theme}>
<BrowserRouter>
<Header {...defaultProps} {...props} />
</BrowserRouter>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
};
@@ -124,14 +127,14 @@ describe('Header', () => {
// and fall back to the default name. We verify the component renders correctly either way.
const logo = screen.getByAltText('MyTube Logo');
expect(logo).toBeInTheDocument();
// Wait for the component to stabilize after async operations
await waitFor(() => {
// The title should be either "TestTube" (if settings succeeds) or "MyTube" (default)
const title = screen.queryByText('TestTube') || screen.queryByText('MyTube');
expect(title).toBeInTheDocument();
}, { timeout: 2000 });
// Logo should always be present
expect(logo).toBeInTheDocument();
});

View File

@@ -28,7 +28,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const response = await axios.get(`${API_URL}/settings`);
const { loginEnabled, isPasswordSet } = response.data;
// Login is required only if enabled AND a password is set
// Login is required if loginEnabled is true (regardless of password or passkey)
if (!loginEnabled || !isPasswordSet) {
setLoginRequired(false);
setIsAuthenticated(true);

View File

@@ -1,4 +1,4 @@
import { ErrorOutline, LockOutlined, Refresh, Visibility, VisibilityOff } from '@mui/icons-material';
import { ErrorOutline, Fingerprint, InfoOutlined, LockOutlined, Refresh, Visibility, VisibilityOff } from '@mui/icons-material';
import {
Alert,
Avatar,
@@ -7,16 +7,20 @@ import {
CircularProgress,
Container,
CssBaseline,
Divider,
IconButton,
InputAdornment,
TextField,
ThemeProvider,
Tooltip,
Typography
} from '@mui/material';
import { startAuthentication } from '@simplewebauthn/browser';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import logo from '../assets/logo.svg';
import AlertModal from '../components/AlertModal';
import ConfirmationModal from '../components/ConfirmationModal';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -30,12 +34,15 @@ const LoginPage: React.FC = () => {
const [error, setError] = useState('');
const [waitTime, setWaitTime] = useState(0); // in milliseconds
const [showResetModal, setShowResetModal] = useState(false);
const [alertOpen, setAlertOpen] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [websiteName, setWebsiteName] = useState('MyTube');
const { t } = useLanguage();
const { login } = useAuth();
const queryClient = useQueryClient();
// Fetch website name from settings
// Fetch website name and settings from settings
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
@@ -50,6 +57,9 @@ const LoginPage: React.FC = () => {
retryDelay: 1000,
});
const passwordLoginAllowed = settingsData?.passwordLoginAllowed !== false;
const allowResetPassword = settingsData?.allowResetPassword !== false;
// Update website name when settings are loaded
useEffect(() => {
if (settingsData && settingsData.websiteName) {
@@ -68,6 +78,24 @@ const LoginPage: React.FC = () => {
retryDelay: 1000,
});
// Check if passkeys exist
const { data: passkeysData } = useQuery({
queryKey: ['passkeys-exists'],
queryFn: async () => {
try {
const response = await axios.get(`${API_URL}/settings/passkeys/exists`, { timeout: 5000 });
return response.data;
} catch (error) {
return { exists: false };
}
},
retry: 1,
retryDelay: 1000,
enabled: !isCheckingConnection && !isConnectionError,
});
const passkeysExist = passkeysData?.exists || false;
// Initialize wait time from server response
useEffect(() => {
if (statusData && statusData.waitTime) {
@@ -75,9 +103,9 @@ const LoginPage: React.FC = () => {
}
}, [statusData]);
// Auto-login if password is not enabled
// Auto-login only if login is not required
useEffect(() => {
if (statusData && statusData.enabled === false) {
if (statusData && statusData.loginRequired === false) {
login();
}
}, [statusData, login]);
@@ -110,6 +138,12 @@ const LoginPage: React.FC = () => {
return `${days} day${days !== 1 ? 's' : ''}`;
};
const showAlert = (title: string, message: string) => {
setAlertTitle(title);
setAlertMessage(message);
setAlertOpen(true);
};
const loginMutation = useMutation({
mutationFn: async (password: string) => {
const response = await axios.post(`${API_URL}/settings/verify-password`, { password });
@@ -120,7 +154,7 @@ const LoginPage: React.FC = () => {
setWaitTime(0); // Reset wait time on success
login();
} else {
setError(t('incorrectPassword'));
showAlert(t('error'), t('incorrectPassword'));
}
},
onError: (err: any) => {
@@ -132,26 +166,22 @@ const LoginPage: React.FC = () => {
const waitTimeMs = responseData.waitTime || 0;
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
setError(
`${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`
);
showAlert(t('error'), `${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else if (err.response.status === 401) {
// Incorrect password - check if wait time is returned
const waitTimeMs = responseData.waitTime || 0;
if (waitTimeMs > 0) {
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
setError(
`${t('incorrectPassword')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`
);
showAlert(t('error'), `${t('incorrectPassword')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else {
setError(t('incorrectPassword'));
showAlert(t('error'), t('incorrectPassword'));
}
} else {
setError(t('loginFailed'));
showAlert(t('error'), t('loginFailed'));
}
} else {
setError(t('loginFailed'));
showAlert(t('error'), t('loginFailed'));
}
}
});
@@ -167,11 +197,58 @@ const LoginPage: React.FC = () => {
setWaitTime(0);
queryClient.invalidateQueries({ queryKey: ['healthCheck'] });
// Show success message
alert(t('resetPasswordSuccess'));
showAlert(t('success'), t('resetPasswordSuccess'));
},
onError: (err: any) => {
console.error('Reset password error:', err);
setError(t('loginFailed'));
showAlert(t('error'), t('loginFailed'));
}
});
// Passkey authentication mutation
const passkeyLoginMutation = useMutation({
mutationFn: async () => {
// Step 1: Get authentication options
const optionsResponse = await axios.post(`${API_URL}/settings/passkeys/authenticate`);
const { options, challenge } = optionsResponse.data;
// Step 2: Start authentication with browser
const assertionResponse = await startAuthentication(options);
// Step 3: Verify authentication
const verifyResponse = await axios.post(`${API_URL}/settings/passkeys/authenticate/verify`, {
body: assertionResponse,
challenge,
});
if (!verifyResponse.data.success) {
throw new Error('Passkey authentication failed');
}
return verifyResponse.data;
},
onSuccess: () => {
setError('');
setWaitTime(0);
login();
},
onError: (err: any) => {
console.error('Passkey login error:', err);
// Extract error message from axios response or error object
let errorMessage = t('passkeyLoginFailed') || 'Passkey authentication failed. Please try again.';
if (err?.response?.data?.error) {
// Backend error message (e.g., "No passkeys registered" or "No passkeys found for RP_ID")
errorMessage = err.response.data.error;
} else if (err?.response?.data?.message) {
errorMessage = err.response.data.message;
} else if (err?.message) {
errorMessage = err.message;
}
showAlert(t('error'), errorMessage);
}
});
@@ -188,6 +265,32 @@ const LoginPage: React.FC = () => {
resetPasswordMutation.mutate();
};
const handlePasskeyLogin = () => {
// Check if we're in a secure context (HTTPS or localhost)
// This is the most important check - WebAuthn requires secure context
if (!window.isSecureContext) {
const hostname = window.location.hostname;
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
if (!isLocalhost) {
showAlert(t('error'), t('passkeyRequiresHttps') || 'WebAuthn requires HTTPS or localhost. Please access the application via HTTPS or use localhost instead of an IP address.');
return;
}
}
// Check if WebAuthn is supported
// Check multiple ways to detect WebAuthn support
const hasWebAuthn =
typeof window.PublicKeyCredential !== 'undefined' ||
(typeof navigator !== 'undefined' && 'credentials' in navigator && 'create' in navigator.credentials);
if (!hasWebAuthn) {
showAlert(t('error'), t('passkeyWebAuthnNotSupported') || 'WebAuthn is not supported in this browser. Please use a modern browser that supports WebAuthn.');
return;
}
passkeyLoginMutation.mutate();
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
@@ -251,55 +354,112 @@ const LoginPage: React.FC = () => {
</Typography>
</Box>
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1, width: '100%' }}>
<TextField
margin="normal"
required
fullWidth
name="password"
label={t('password')}
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
disabled={waitTime > 0 || loginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : t('signIn')}
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<Refresh />}
onClick={() => setShowResetModal(true)}
sx={{ mb: 2 }}
disabled={resetPasswordMutation.isPending}
>
{t('resetPassword')}
</Button>
{passwordLoginAllowed && (
<>
<TextField
margin="normal"
required
fullWidth
name="password"
label={t('password')}
type={showPassword ? 'text' : 'password'}
id="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
disabled={waitTime > 0 || loginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={t('togglePasswordVisibility')}
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
)
}
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : t('signIn')}
</Button>
{passkeysExist && (
<>
<Divider sx={{ my: 2 }}>OR</Divider>
<Button
fullWidth
variant="outlined"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
</Button>
</>
)}
</>
)}
{!passwordLoginAllowed && passkeysExist && (
<Button
fullWidth
variant="contained"
startIcon={<Fingerprint />}
onClick={handlePasskeyLogin}
sx={{ mt: 3, mb: 2 }}
disabled={passkeyLoginMutation.isPending || waitTime > 0}
>
{passkeyLoginMutation.isPending
? (t('authenticating') || 'Authenticating...')
: (t('loginWithPasskey') || 'Login with Passkey')}
</Button>
)}
{allowResetPassword && (
<Button
fullWidth
variant="outlined"
startIcon={<Refresh />}
onClick={() => setShowResetModal(true)}
sx={{ mb: 2 }}
disabled={resetPasswordMutation.isPending}
>
{t('resetPassword')}
</Button>
)}
{!allowResetPassword && passwordLoginAllowed && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', mb: 2 }}>
<Tooltip title={t('resetPasswordDisabledInfo') || 'Click for information about resetting password'}>
<IconButton
onClick={() => showAlert(
t('resetPassword') || 'Reset Password',
t('resetPasswordDisabledInfo') || 'Password reset is disabled. To reset your password, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nThis will generate a new random password and enable password login.'
)}
color="primary"
sx={{
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<InfoOutlined />
</IconButton>
</Tooltip>
</Box>
)}
<Box sx={{ minHeight: waitTime > 0 || (error && waitTime === 0) ? 'auto' : 0, mt: 2 }}>
{waitTime > 0 && (
<Alert severity="warning" sx={{ width: '100%' }}>
@@ -327,6 +487,12 @@ const LoginPage: React.FC = () => {
cancelText={t('cancel')}
isDanger={true}
/>
<AlertModal
open={alertOpen}
onClose={() => setAlertOpen(false)}
title={alertTitle}
message={alertMessage}
/>
</ThemeProvider>
);
};

View File

@@ -59,6 +59,8 @@ export interface Settings {
loginEnabled: boolean;
password?: string;
isPasswordSet?: boolean;
passwordLoginAllowed?: boolean;
allowResetPassword?: boolean;
defaultAutoPlay: boolean;
defaultAutoLoop: boolean;
maxConcurrentDownloads: number;

View File

@@ -1,143 +1,297 @@
import { describe, expect, it } from 'vitest';
import { formatDate, formatDuration, formatSize, parseDuration } from '../formatUtils';
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatDate,
formatDuration,
formatRelativeDownloadTime,
formatSize,
parseDuration,
} from "../formatUtils";
describe('formatUtils', () => {
describe('parseDuration', () => {
it('should return 0 for undefined', () => {
expect(parseDuration(undefined)).toBe(0);
});
it('should return number as-is', () => {
expect(parseDuration(100)).toBe(100);
expect(parseDuration(0)).toBe(0);
});
it('should parse HH:MM:SS format', () => {
expect(parseDuration('1:30:45')).toBe(5445); // 1*3600 + 30*60 + 45 = 3600 + 1800 + 45
expect(parseDuration('0:5:30')).toBe(330); // 0*3600 + 5*60 + 30 = 0 + 300 + 30
expect(parseDuration('2:0:0')).toBe(7200); // 2*3600 + 0*60 + 0 = 7200
});
it('should parse MM:SS format', () => {
expect(parseDuration('5:30')).toBe(330); // 5*60 + 30
expect(parseDuration('10:15')).toBe(615); // 10*60 + 15
expect(parseDuration('0:45')).toBe(45);
});
it('should parse numeric string', () => {
expect(parseDuration('100')).toBe(100);
expect(parseDuration('0')).toBe(0);
});
it('should return 0 for invalid string', () => {
expect(parseDuration('invalid')).toBe(0);
// 'abc:def' will be parsed as NaN for each part, but the function
// will try parseInt on the whole string which also returns NaN -> 0
expect(parseDuration('abc:def')).toBe(0);
expect(parseDuration('not-a-number')).toBe(0);
});
describe("formatUtils", () => {
describe("parseDuration", () => {
it("should return 0 for undefined", () => {
expect(parseDuration(undefined)).toBe(0);
});
describe('formatDuration', () => {
it('should return 00:00 for undefined', () => {
expect(formatDuration(undefined)).toBe('00:00');
});
it('should return formatted string as-is if already formatted', () => {
expect(formatDuration('1:30:45')).toBe('1:30:45');
expect(formatDuration('5:30')).toBe('5:30');
});
it('should format seconds to MM:SS', () => {
expect(formatDuration(65)).toBe('1:05'); // 1 minute 5 seconds
expect(formatDuration(125)).toBe('2:05'); // 2 minutes 5 seconds
expect(formatDuration(45)).toBe('0:45'); // 45 seconds
expect(formatDuration(0)).toBe('00:00');
});
it('should format seconds to H:MM:SS for hours', () => {
expect(formatDuration(3665)).toBe('1:01:05'); // 1 hour 1 minute 5 seconds
expect(formatDuration(3600)).toBe('1:00:00'); // 1 hour
expect(formatDuration(7325)).toBe('2:02:05'); // 2 hours 2 minutes 5 seconds
});
it('should format numeric string', () => {
expect(formatDuration('65')).toBe('1:05');
expect(formatDuration('3665')).toBe('1:01:05');
});
it('should return 00:00 for invalid input', () => {
expect(formatDuration('invalid')).toBe('00:00');
expect(formatDuration(NaN)).toBe('00:00');
});
it("should return number as-is", () => {
expect(parseDuration(100)).toBe(100);
expect(parseDuration(0)).toBe(0);
});
describe('formatSize', () => {
it('should return "0 B" for undefined', () => {
expect(formatSize(undefined)).toBe('0 B');
});
it('should format bytes', () => {
expect(formatSize(0)).toBe('0 B');
expect(formatSize(500)).toBe('500 B');
expect(formatSize(1023)).toBe('1023 B');
});
it('should format kilobytes', () => {
expect(formatSize(1024)).toBe('1 KB');
expect(formatSize(1536)).toBe('1.5 KB');
expect(formatSize(2048)).toBe('2 KB');
expect(formatSize(10240)).toBe('10 KB');
});
it('should format megabytes', () => {
expect(formatSize(1048576)).toBe('1 MB'); // 1024 * 1024
expect(formatSize(1572864)).toBe('1.5 MB');
expect(formatSize(5242880)).toBe('5 MB');
});
it('should format gigabytes', () => {
expect(formatSize(1073741824)).toBe('1 GB'); // 1024^3
expect(formatSize(2147483648)).toBe('2 GB');
});
it('should format terabytes', () => {
expect(formatSize(1099511627776)).toBe('1 TB'); // 1024^4
});
it('should format numeric string', () => {
expect(formatSize('1024')).toBe('1 KB');
expect(formatSize('1048576')).toBe('1 MB');
});
it('should return "0 B" for invalid input', () => {
expect(formatSize('invalid')).toBe('0 B');
expect(formatSize(NaN)).toBe('0 B');
});
it("should parse HH:MM:SS format", () => {
expect(parseDuration("1:30:45")).toBe(5445); // 1*3600 + 30*60 + 45 = 3600 + 1800 + 45
expect(parseDuration("0:5:30")).toBe(330); // 0*3600 + 5*60 + 30 = 0 + 300 + 30
expect(parseDuration("2:0:0")).toBe(7200); // 2*3600 + 0*60 + 0 = 7200
});
describe('formatDate', () => {
it('should return "Unknown date" for undefined', () => {
expect(formatDate(undefined)).toBe('Unknown date');
});
it('should return "Unknown date" for invalid length', () => {
expect(formatDate('202301')).toBe('Unknown date');
expect(formatDate('202301011')).toBe('Unknown date');
expect(formatDate('2023')).toBe('Unknown date');
});
it('should format YYYYMMDD to YYYY-MM-DD', () => {
expect(formatDate('20230101')).toBe('2023-01-01');
expect(formatDate('20231225')).toBe('2023-12-25');
expect(formatDate('20200101')).toBe('2020-01-01');
expect(formatDate('20230228')).toBe('2023-02-28');
});
it('should handle edge cases', () => {
expect(formatDate('19991231')).toBe('1999-12-31');
expect(formatDate('20991231')).toBe('2099-12-31');
});
it("should parse MM:SS format", () => {
expect(parseDuration("5:30")).toBe(330); // 5*60 + 30
expect(parseDuration("10:15")).toBe(615); // 10*60 + 15
expect(parseDuration("0:45")).toBe(45);
});
it("should parse numeric string", () => {
expect(parseDuration("100")).toBe(100);
expect(parseDuration("0")).toBe(0);
});
it("should return 0 for invalid string", () => {
expect(parseDuration("invalid")).toBe(0);
// 'abc:def' will be parsed as NaN for each part, but the function
// will try parseInt on the whole string which also returns NaN -> 0
expect(parseDuration("abc:def")).toBe(0);
expect(parseDuration("not-a-number")).toBe(0);
});
});
describe("formatDuration", () => {
it("should return 00:00 for undefined", () => {
expect(formatDuration(undefined)).toBe("00:00");
});
it("should return formatted string as-is if already formatted", () => {
expect(formatDuration("1:30:45")).toBe("1:30:45");
expect(formatDuration("5:30")).toBe("5:30");
});
it("should format seconds to MM:SS", () => {
expect(formatDuration(65)).toBe("1:05"); // 1 minute 5 seconds
expect(formatDuration(125)).toBe("2:05"); // 2 minutes 5 seconds
expect(formatDuration(45)).toBe("0:45"); // 45 seconds
expect(formatDuration(0)).toBe("00:00");
});
it("should format seconds to H:MM:SS for hours", () => {
expect(formatDuration(3665)).toBe("1:01:05"); // 1 hour 1 minute 5 seconds
expect(formatDuration(3600)).toBe("1:00:00"); // 1 hour
expect(formatDuration(7325)).toBe("2:02:05"); // 2 hours 2 minutes 5 seconds
});
it("should format numeric string", () => {
expect(formatDuration("65")).toBe("1:05");
expect(formatDuration("3665")).toBe("1:01:05");
});
it("should return 00:00 for invalid input", () => {
expect(formatDuration("invalid")).toBe("00:00");
expect(formatDuration(NaN)).toBe("00:00");
});
});
describe("formatSize", () => {
it('should return "0 B" for undefined', () => {
expect(formatSize(undefined)).toBe("0 B");
});
it("should format bytes", () => {
expect(formatSize(0)).toBe("0 B");
expect(formatSize(500)).toBe("500 B");
expect(formatSize(1023)).toBe("1023 B");
});
it("should format kilobytes", () => {
expect(formatSize(1024)).toBe("1 KB");
expect(formatSize(1536)).toBe("1.5 KB");
expect(formatSize(2048)).toBe("2 KB");
expect(formatSize(10240)).toBe("10 KB");
});
it("should format megabytes", () => {
expect(formatSize(1048576)).toBe("1 MB"); // 1024 * 1024
expect(formatSize(1572864)).toBe("1.5 MB");
expect(formatSize(5242880)).toBe("5 MB");
});
it("should format gigabytes", () => {
expect(formatSize(1073741824)).toBe("1 GB"); // 1024^3
expect(formatSize(2147483648)).toBe("2 GB");
});
it("should format terabytes", () => {
expect(formatSize(1099511627776)).toBe("1 TB"); // 1024^4
});
it("should format numeric string", () => {
expect(formatSize("1024")).toBe("1 KB");
expect(formatSize("1048576")).toBe("1 MB");
});
it('should return "0 B" for invalid input', () => {
expect(formatSize("invalid")).toBe("0 B");
expect(formatSize(NaN)).toBe("0 B");
});
});
describe("formatDate", () => {
it('should return "Unknown date" for undefined', () => {
expect(formatDate(undefined)).toBe("Unknown date");
});
it('should return "Unknown date" for invalid length', () => {
expect(formatDate("202301")).toBe("Unknown date");
expect(formatDate("202301011")).toBe("Unknown date");
expect(formatDate("2023")).toBe("Unknown date");
});
it("should format YYYYMMDD to YYYY-MM-DD", () => {
expect(formatDate("20230101")).toBe("2023-01-01");
expect(formatDate("20231225")).toBe("2023-12-25");
expect(formatDate("20200101")).toBe("2020-01-01");
expect(formatDate("20230228")).toBe("2023-02-28");
});
it("should handle edge cases", () => {
expect(formatDate("19991231")).toBe("1999-12-31");
expect(formatDate("20991231")).toBe("2099-12-31");
});
});
describe("formatRelativeDownloadTime", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
const mockTranslation = (
key: string,
replacements?: Record<string, string | number>
) => {
const translations: Record<string, string> = {
justNow: "Just now",
hoursAgo: `${replacements?.hours || 0} hours ago`,
today: "Today",
thisWeek: "This week",
weeksAgo: `${replacements?.weeks || 0} weeks ago`,
unknownDate: "Unknown date",
};
return translations[key] || key;
};
it('should return "Just now" for less than 1 hour', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const thirtyMinutesAgo = new Date("2023-01-01T11:30:00Z").toISOString();
expect(
formatRelativeDownloadTime(thirtyMinutesAgo, undefined, mockTranslation)
).toBe("Just now");
});
it('should return "X hours ago" for 1-5 hours', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const twoHoursAgo = new Date("2023-01-01T10:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(twoHoursAgo, undefined, mockTranslation)
).toBe("2 hours ago");
});
it('should return "Today" for 5-24 hours', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const tenHoursAgo = new Date("2023-01-01T02:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(tenHoursAgo, undefined, mockTranslation)
).toBe("Today");
});
it('should return "This week" for 1-7 days', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const threeDaysAgo = new Date("2022-12-29T12:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(threeDaysAgo, undefined, mockTranslation)
).toBe("This week");
});
it('should return "X weeks ago" for 1-4 weeks', () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const twoWeeksAgo = new Date("2022-12-18T12:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(twoWeeksAgo, undefined, mockTranslation)
).toBe("2 weeks ago");
});
it("should return formatted date for > 4 weeks", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const sixWeeksAgo = new Date("2022-11-20T12:00:00Z").toISOString();
const result = formatRelativeDownloadTime(
sixWeeksAgo,
"20221120",
mockTranslation
);
expect(result).toBe("2022-11-20");
});
it("should use originalDate when provided for > 4 weeks", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const sixWeeksAgo = new Date("2022-11-20T12:00:00Z").toISOString();
expect(
formatRelativeDownloadTime(sixWeeksAgo, "20221120", mockTranslation)
).toBe("2022-11-20");
});
it('should fallback to "Unknown date" when no timestamp provided', () => {
expect(
formatRelativeDownloadTime(undefined, undefined, mockTranslation)
).toBe("Unknown date");
});
it("should use originalDate when no timestamp provided", () => {
expect(
formatRelativeDownloadTime(undefined, "20230101", mockTranslation)
).toBe("2023-01-01");
});
it("should fallback to English when no translation function provided", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
const thirtyMinutesAgo = new Date("2023-01-01T11:30:00Z").toISOString();
expect(formatRelativeDownloadTime(thirtyMinutesAgo)).toBe("Just now");
});
it("should handle invalid date", () => {
expect(
formatRelativeDownloadTime("invalid-date", undefined, mockTranslation)
).toBe("Unknown date");
});
it("should use originalDate when date is invalid", () => {
expect(
formatRelativeDownloadTime("invalid-date", "20230101", mockTranslation)
).toBe("2023-01-01");
});
it("should format date in UTC to avoid timezone issues", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
// Use a date that could be affected by timezone (midnight UTC)
const sixWeeksAgo = new Date("2022-11-20T00:00:00Z").toISOString();
// Should format as 2022-11-20 regardless of system timezone
const result = formatRelativeDownloadTime(
sixWeeksAgo,
undefined,
mockTranslation
);
expect(result).toBe("2022-11-20");
});
it("should handle date formatting across timezone boundaries", () => {
const now = new Date("2023-01-01T12:00:00Z");
vi.setSystemTime(now);
// Test with a date near midnight UTC to catch timezone edge cases
const sixWeeksAgo = new Date("2022-11-20T23:59:59Z").toISOString();
const result = formatRelativeDownloadTime(
sixWeeksAgo,
undefined,
mockTranslation
);
expect(result).toBe("2022-11-20");
});
});
});

View File

@@ -81,6 +81,106 @@ export const formatDate = (dateString?: string) => {
return `${year}-${month}-${day}`;
};
/**
* Format relative time from download timestamp to current time
* 0 - 1 hour: "Just now"
* 1 hour - 5 hours: "X hours ago"
* 5 hours - 24 hours: "Today"
* 1 day - 7 days: "This week"
* 1 week - 4 weeks: "X weeks ago"
* > 4 weeks: show actual date
*/
export const formatRelativeDownloadTime = (
downloadTimestamp?: string,
originalDate?: string,
t?: (key: string, replacements?: Record<string, string | number>) => string
): string => {
const getTranslation = (
key: string,
replacements?: Record<string, string | number>
): string => {
if (t) {
return t(key as any, replacements);
}
// Fallback to English if no translation function provided
const fallbacks: Record<string, string> = {
justNow: "Just now",
hoursAgo: "{hours} hours ago",
today: "Today",
thisWeek: "This week",
weeksAgo: "{weeks} weeks ago",
unknownDate: "Unknown date",
};
let text = fallbacks[key] || key;
if (replacements) {
Object.entries(replacements).forEach(([placeholder, value]) => {
text = text.replace(`{${placeholder}}`, String(value));
});
}
return text;
};
if (!downloadTimestamp) {
// Fallback to original date format if no download timestamp
return originalDate
? formatDate(originalDate)
: getTranslation("unknownDate");
}
const downloadDate = new Date(downloadTimestamp);
const now = new Date();
// Check if date is valid
if (isNaN(downloadDate.getTime())) {
return originalDate
? formatDate(originalDate)
: getTranslation("unknownDate");
}
const diffMs = now.getTime() - downloadDate.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
const diffDays = diffMs / (1000 * 60 * 60 * 24);
const diffWeeks = diffDays / 7;
// 0 - 1 hour: "Just now"
if (diffHours < 1) {
return getTranslation("justNow");
}
// 1 hour - 5 hours: "X hours ago"
if (diffHours >= 1 && diffHours < 5) {
const hours = Math.floor(diffHours);
return getTranslation("hoursAgo", { hours });
}
// 5 hours - 24 hours: "Today"
if (diffHours >= 5 && diffHours < 24) {
return getTranslation("today");
}
// 1 day - 7 days: "This week"
if (diffDays >= 1 && diffDays < 7) {
return getTranslation("thisWeek");
}
// 1 week - 4 weeks: "X周前" / "X weeks ago"
if (diffWeeks >= 1 && diffWeeks < 4) {
const weeks = Math.floor(diffWeeks);
return getTranslation("weeksAgo", { weeks });
}
// > 4 weeks: show actual date
if (originalDate) {
return formatDate(originalDate);
}
// Format download date as YYYY-MM-DD if no original date
// Use UTC methods to ensure timezone independence
const year = downloadDate.getUTCFullYear();
const month = String(downloadDate.getUTCMonth() + 1).padStart(2, "0");
const day = String(downloadDate.getUTCDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
/**
* Generate timestamp string in format YYYY-MM-DD-HH-MM-SS
* Matches the backend generateTimestamp() function format
@@ -101,14 +201,17 @@ export const generateTimestamp = (): string => {
* If path is already a full URL (starts with http:// or https://), return it as is
* Otherwise, prepend BACKEND_URL
*/
export const getFileUrl = (path: string | null | undefined, backendUrl: string): string | undefined => {
export const getFileUrl = (
path: string | null | undefined,
backendUrl: string
): string | undefined => {
if (!path) return undefined;
// Check if path is already a full URL
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
// Otherwise, prepend backend URL
return `${backendUrl}${path}`;
};

View File

@@ -52,6 +52,10 @@ export const ar = {
videoColumns: "أعمدة الفيديو (الصفحة الرئيسية)",
columnsCount: "{count} أعمدة",
enableLogin: "تفعيل حماية تسجيل الدخول",
allowPasswordLogin: "السماح بتسجيل الدخول بكلمة المرور",
allowPasswordLoginHelper: "عند التعطيل، لن يكون تسجيل الدخول بكلمة المرور متاحًا. يجب أن يكون لديك مفتاح وصول واحد على الأقل لتعطيل تسجيل الدخول بكلمة المرور.",
allowResetPassword: "السماح بإعادة تعيين كلمة المرور",
allowResetPasswordHelper: "عند التعطيل، لن يتم عرض زر إعادة تعيين كلمة المرور في صفحة تسجيل الدخول وستتم حظر واجهة برمجة تطبيقات إعادة تعيين كلمة المرور.",
password: "كلمة المرور",
enterPassword: "أدخل كلمة المرور",
togglePasswordVisibility: "تبديل رؤية كلمة المرور",
@@ -129,8 +133,10 @@ export const ar = {
showYoutubeSearch: "عرض نتائج بحث YouTube",
visitorMode: "وضع الزائر (للقراءة فقط)",
visitorModeReadOnly: "وضع الزائر: للقراءة فقط",
visitorModeDescription: "وضع القراءة فقط. لن تكون مقاطع الفيديو المخفية مرئية للزوار.",
visitorModePasswordPrompt: "يرجى إدخال كلمة مرور الموقع لتغيير إعدادات وضع الزائر.",
visitorModeDescription:
"وضع القراءة فقط. لن تكون مقاطع الفيديو المخفية مرئية للزوار.",
visitorModePasswordPrompt:
"يرجى إدخال كلمة مرور الموقع لتغيير إعدادات وضع الزائر.",
cleanupTempFilesSuccess: "تم حذف {count} ملف (ملفات) مؤقت بنجاح.",
cleanupTempFilesFailed: "فشل تنظيف الملفات المؤقتة",
@@ -159,11 +165,13 @@ export const ar = {
apiUrlHelper: "مثال: https://your-alist-instance.com/api/fs/put",
token: "الرمز المميز (Token)",
publicUrl: "عنوان URL العام",
publicUrlHelper: "النطاق العام للوصول إلى الملفات (مثال: https://your-cloudflare-tunnel-domain.com). إذا تم تعيينه، سيتم استخدامه بدلاً من عنوان API للوصول إلى الملفات.",
publicUrlHelper:
"النطاق العام للوصول إلى الملفات (مثال: https://your-cloudflare-tunnel-domain.com). إذا تم تعيينه، سيتم استخدامه بدلاً من عنوان API للوصول إلى الملفات.",
uploadPath: "مسار التحميل",
cloudDrivePathHelper: "مسار الدليل في التخزين السحابي، مثال: /mytube-uploads",
scanPaths: "مسارات المسح",
scanPathsHelper: "مسار واحد في كل سطر. سيتم مسح مقاطع الفيديو من هذه المسارات. إذا كانت فارغة، سيتم استخدام مسار التحميل. مثال:\n/a/أفلام\n/b/وثائقيات",
scanPathsHelper:
"مسار واحد في كل سطر. سيتم مسح مقاطع الفيديو من هذه المسارات. إذا كانت فارغة، سيتم استخدام مسار التحميل. مثال:\n/a/أفلام\n/b/وثائقيات",
cloudDriveNote:
"بعد تفعيل هذه الميزة، سيتم تحميل مقاطع الفيديو التي تم تنزيلها حديثًا تلقائيًا إلى التخزين السحابي وسيتم حذف الملفات المحلية. سيتم تشغيل مقاطع الفيديو من التخزين السحابي عبر الوكيل.",
cloudScanAdded: "تمت الإضافة من السحابة",
@@ -171,7 +179,8 @@ export const ar = {
testConnection: "اختبار الاتصال",
sync: "مزامنة",
syncToCloud: "مزامنة ثنائية الاتجاه",
syncWarning: "ستقوم هذه العملية برفع مقاطع الفيديو المحلية إلى السحابة ومسح التخزين السحابي بحثًا عن ملفات جديدة. سيتم حذف الملفات المحلية بعد الرفع.",
syncWarning:
"ستقوم هذه العملية برفع مقاطع الفيديو المحلية إلى السحابة ومسح التخزين السحابي بحثًا عن ملفات جديدة. سيتم حذف الملفات المحلية بعد الرفع.",
syncing: "جاري المزامنة...",
syncCompleted: "اكتملت المزامنة",
syncFailed: "فشلت المزامنة",
@@ -188,9 +197,11 @@ export const ar = {
uploadingVideo: "جاري الرفع: {title}",
clearThumbnailCache: "مسح ذاكرة التخزين المؤقت للصور المصغرة",
clearing: "جاري المسح...",
clearThumbnailCacheSuccess: "تم مسح ذاكرة التخزين المؤقت للصور المصغرة بنجاح. سيتم إعادة إنشاء الصور المصغرة عند الوصول إليها في المرة القادمة.",
clearThumbnailCacheSuccess:
"تم مسح ذاكرة التخزين المؤقت للصور المصغرة بنجاح. سيتم إعادة إنشاء الصور المصغرة عند الوصول إليها في المرة القادمة.",
clearThumbnailCacheError: "فشل مسح ذاكرة التخزين المؤقت للصور المصغرة",
clearThumbnailCacheConfirmMessage: "سيؤدي هذا إلى مسح جميع الصور المصغرة المخزنة مؤقتًا محليًا لمقاطع الفيديو السحابية. سيتم إعادة إنشاء الصور المصغرة من التخزين السحابي عند الوصول إليها في المرة القادمة. هل تريد المتابعة؟",
clearThumbnailCacheConfirmMessage:
"سيؤدي هذا إلى مسح جميع الصور المصغرة المخزنة مؤقتًا محليًا لمقاطع الفيديو السحابية. سيتم إعادة إنشاء الصور المصغرة من التخزين السحابي عند الوصول إليها في المرة القادمة. هل تريد المتابعة؟",
// Manage
manageContent: "إدارة المحتوى",
@@ -280,7 +291,8 @@ export const ar = {
openInExternalPlayer: "فتح في مشغل خارجي",
playWith: "تشغيل بواسطة...",
deleteAllFilteredVideos: "حذف جميع الفيديوهات المصفاة",
confirmDeleteFilteredVideos: "هل أنت متأكد أنك تريد حذف {count} فيديو مصفى بواسطة العلامات المحددة؟",
confirmDeleteFilteredVideos:
"هل أنت متأكد أنك تريد حذف {count} فيديو مصفى بواسطة العلامات المحددة؟",
deleteFilteredVideosSuccess: "تم حذف {count} فيديو بنجاح.",
deletingVideos: "جاري حذف الفيديوهات...",
@@ -302,10 +314,27 @@ export const ar = {
resetPasswordConfirm: "إعادة التعيين",
resetPasswordSuccess:
"تم إعادة تعيين كلمة المرور. تحقق من سجلات الخادم للحصول على كلمة المرور الجديدة.",
resetPasswordDisabledInfo: "تم تعطيل إعادة تعيين كلمة المرور. لإعادة تعيين كلمة المرور، قم بتشغيل الأمر التالي في دليل الخادم:\n\nnpm run reset-password\n\nأو:\n\nts-node scripts/reset-password.ts\n\nسيؤدي هذا إلى إنشاء كلمة مرور عشوائية جديدة وتمكين تسجيل الدخول بكلمة المرور.",
waitTimeMessage: "يرجى الانتظار {time} قبل المحاولة مرة أخرى.",
tooManyAttempts: "محاولات فاشلة كثيرة جداً.",
// Passkeys
createPasskey: "إنشاء مفتاح مرور",
creatingPasskey: "جاري الإنشاء...",
passkeyCreated: "تم إنشاء مفتاح المرور بنجاح",
passkeyCreationFailed: "فشل إنشاء مفتاح المرور. يرجى المحاولة مرة أخرى.",
removePasskeys: "إزالة جميع مفاتيح المرور",
removePasskeysTitle: "إزالة جميع مفاتيح المرور",
removePasskeysMessage:
"هل أنت متأكد من أنك تريد إزالة جميع مفاتيح المرور؟ لا يمكن التراجع عن هذا الإجراء.",
passkeysRemoved: "تم إزالة جميع مفاتيح المرور",
passkeysRemoveFailed: "فشل إزالة مفاتيح المرور. يرجى المحاولة مرة أخرى.",
loginWithPasskey: "تسجيل الدخول بمفتاح المرور",
authenticating: "جاري المصادقة...",
passkeyLoginFailed: "فشلت مصادقة مفتاح المرور. يرجى المحاولة مرة أخرى.",
linkCopied: "تم نسخ الرابط إلى الحافظة",
copyFailed: "فشل نسخ الرابط",
passkeyRequiresHttps: "يتطلب WebAuthn استخدام HTTPS أو localhost. يرجى الدخول إلى التطبيق عبر HTTPS أو استخدام localhost بدلاً من عنوان IP.",
passkeyWebAuthnNotSupported: "WebAuthn غير مدعوم في هذا المتصفح. يرجى استخدام متصفح حديث يدعم WebAuthn.",
// Collection Page
loadingCollection: "جاري تحميل المجموعة...",
@@ -366,6 +395,11 @@ export const ar = {
unknownDate: "تاريخ غير معروف",
part: "جزء",
collection: "مجموعة",
justNow: "الآن",
hoursAgo: "منذ {hours} ساعة",
today: "اليوم",
thisWeek: "هذا الأسبوع",
weeksAgo: "منذ {weeks} أسبوع",
// Upload Modal
selectVideoFile: "اختر ملف فيديو",
@@ -382,7 +416,8 @@ export const ar = {
authorOrPlaylist: "المؤلف / قائمة التشغيل",
playlistDetected: "تم اكتشاف قائمة تشغيل",
playlistHasVideos: "تحتوي قائمة التشغيل هذه على {count} فيديوهات.",
downloadPlaylistAndCreateCollection: "هل تريد تحميل فيديوهات قائمة التشغيل وإنشاء مجموعة لها؟",
downloadPlaylistAndCreateCollection:
"هل تريد تحميل فيديوهات قائمة التشغيل وإنشاء مجموعة لها؟",
collectionHasVideos: "تحتوي هذه المجموعة من Bilibili على {count} فيديوهات.",
seriesHasVideos: "تحتوي هذه السلسلة من Bilibili على {count} فيديوهات.",
videoHasParts: "يحتوي هذا الفيديو من Bilibili على {count} أجزاء.",
@@ -458,11 +493,13 @@ export const ar = {
confirmCancelTask: "هل أنت متأكد أنك تريد إلغاء مهمة التنزيل لـ {author}؟",
taskCancelled: "تم إلغاء المهمة بنجاح",
deleteTask: "حذف المهمة",
confirmDeleteTask: "هل أنت متأكد أنك تريد حذف سجل المهمة لـ {author}؟ لا يمكن التراجع عن هذا الإجراء.",
confirmDeleteTask:
"هل أنت متأكد أنك تريد حذف سجل المهمة لـ {author}؟ لا يمكن التراجع عن هذا الإجراء.",
taskDeleted: "تم حذف المهمة بنجاح",
clearFinishedTasks: "مسح المهام المنتهية",
tasksCleared: "تم مسح المهام المنتهية بنجاح",
confirmClearFinishedTasks: "هل أنت متأكد أنك تريد مسح جميع المهام المنتهية (المكتملة، الملغاة)؟ سيؤدي هذا إلى إزالتها من القائمة ولكن لن يحذف أي ملفات تم تنزيلها.",
confirmClearFinishedTasks:
"هل أنت متأكد أنك تريد مسح جميع المهام المنتهية (المكتملة، الملغاة)؟ سيؤدي هذا إلى إزالتها من القائمة ولكن لن يحذف أي ملفات تم تنزيلها.",
clear: "مسح",
// Instruction Page
instructionSection1Title: "1. التنزيل وإدارة المهام",
@@ -586,7 +623,8 @@ export const ar = {
lastBackupDate: "تاريخ آخر نسخة احتياطية",
noBackupAvailable: "لا توجد نسخة احتياطية متاحة",
deleteAuthor: "حذف المؤلف",
deleteAuthorConfirmation: "هل أنت متأكد أنك تريد حذف المؤلف {author}؟ سيؤدي هذا إلى حذف جميع مقاطع الفيديو المرتبطة بهذا المؤلف.",
deleteAuthorConfirmation:
"هل أنت متأكد أنك تريد حذف المؤلف {author}؟ سيؤدي هذا إلى حذف جميع مقاطع الفيديو المرتبطة بهذا المؤلف.",
authorDeletedSuccessfully: "تم حذف المؤلف بنجاح",
failedToDeleteAuthor: "فشل حذف المؤلف",
@@ -594,7 +632,8 @@ export const ar = {
cloudflaredTunnel: "نفق Cloudflare",
enableCloudflaredTunnel: "تمكين نفق Cloudflare",
cloudflaredToken: "رمز النفق (اختياري)",
cloudflaredTokenHelper: "الصق رمز النفق الخاص بك هنا، أو اتركه فارغًا لاستخدام نفق سريع عشوائي.",
cloudflaredTokenHelper:
"الصق رمز النفق الخاص بك هنا، أو اتركه فارغًا لاستخدام نفق سريع عشوائي.",
waitingForUrl: "في انتظار عنوان النفق السريع URL...",
running: "يعمل",
stopped: "متوقف",
@@ -602,8 +641,10 @@ export const ar = {
accountTag: "علامة الحساب",
copied: "تم النسخ!",
clickToCopy: "انقر للنسخ",
quickTunnelWarning: "تتغير عناوين URL للنفق السريع في كل مرة يتم فيها إعادة تشغيل النفق.",
managedInDashboard: "تتم إدارة اسم المضيف العام في لوحة تحكم Cloudflare Zero Trust الخاصة بك.",
quickTunnelWarning:
"تتغير عناوين URL للنفق السريع في كل مرة يتم فيها إعادة تشغيل النفق.",
managedInDashboard:
"تتم إدارة اسم المضيف العام في لوحة تحكم Cloudflare Zero Trust الخاصة بك.",
failedToDownloadVideo: "فشل تنزيل الفيديو. يرجى المحاولة مرة أخرى.",
failedToDownload: "فشل التنزيل. يرجى المحاولة مرة أخرى.",
playlistDownloadStarted: "بدأ تنزيل قائمة التشغيل",
@@ -612,24 +653,28 @@ export const ar = {
copyUrl: "نسخ الرابط",
new: "جديد",
// Task Hooks
taskHooks: 'خطافات المهام',
taskHooksDescription: 'نفذ أوامر shell مخصصة في نقاط محددة من دورة حياة المهمة. متغيرات البيئة المتاحة: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'تحذير: يتم تشغيل الأوامر بصلاحيات الخادم. استخدم بحذر.',
hookTaskBeforeStart: 'قبل بدء المهمة',
hookTaskBeforeStartHelper: 'ينفذ قبل بدء التنزيل.',
hookTaskSuccess: 'نجاح المهمة',
hookTaskSuccessHelper: 'ينفذ بعد التنزيل الناجح، قبل الرفع السحابي/الحذف (ينتظر الاكتمال).',
hookTaskFail: 'فشل المهمة',
hookTaskFailHelper: 'ينفذ عند فشل المهمة.',
hookTaskCancel: 'إلغاء المهمة',
hookTaskCancelHelper: 'ينفذ عند إلغاء المهمة يدوياً.',
found: 'موجود',
notFound: 'غير معين',
deleteHook: 'حذف سكريبت الخطاف',
confirmDeleteHook: 'هل أنت متأكد أنك تريد حذف سكريبت الخطاف هذا؟',
uploadHook: 'رفع .sh',
taskHooks: "خطافات المهام",
taskHooksDescription:
"نفذ أوامر shell مخصصة في نقاط محددة من دورة حياة المهمة. متغيرات البيئة المتاحة: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning: "تحذير: يتم تشغيل الأوامر بصلاحيات الخادم. استخدم بحذر.",
enterPasswordToUploadHook: "الرجاء إدخال كلمة المرور لتحميل نص Hook هذا.",
riskCommandDetected: "تم اكتشاف أمر خطر: {command}. تم رفض التحميل.",
hookTaskBeforeStart: "قبل بدء المهمة",
hookTaskBeforeStartHelper: "ينفذ قبل بدء التنزيل.",
hookTaskSuccess: "نجاح المهمة",
hookTaskSuccessHelper:
"ينفذ بعد التنزيل الناجح، قبل الرفع السحابي/الحذف (ينتظر الاكتمال).",
hookTaskFail: "فشل المهمة",
hookTaskFailHelper: "ينفذ عند فشل المهمة.",
hookTaskCancel: "إلغاء المهمة",
hookTaskCancelHelper: "ينفذ عند إلغاء المهمة يدوياً.",
found: "موجود",
notFound: "غير معين",
deleteHook: "حذف سكريبت الخطاف",
confirmDeleteHook: "هل أنت متأكد أنك تريد حذف سكريبت الخطاف هذا؟",
uploadHook: "رفع .sh",
disclaimerTitle: "إخلاء المسؤولية",
disclaimerText: "1. الغرض والقيود\nهذا البرنامج (بما في ذلك الكود والوثائق) مخصص فقط للتعلم الشخصي والبحث والتبادل التقني. يُحظر تمامًا استخدام هذا البرنامج لأي أغراض تجارية أو لأي أنشطة غير قانونية تنتهك القوانين واللوائح المحلية.\n\n2. المسؤولية\nالمطور ليس على علم ولا يملك أي سيطرة على كيفية استخدام المستخدمين لهذا البرنامج. يتحمل المستخدم وحده أي مسؤوليات قانونية أو نزاعات أو أضرار تنشأ عن الاستخدام غير القانوني أو غير السليم لهذا البرنامج (بما في ذلك على سبيل المثال لا الحصر انتهاك حقوق الطبع والنشر). لا يتحمل المطور أي مسؤولية مباشرة أو غير مباشرة أو مشتركة.\n\n3. التعديلات والتوزيع\nهذا المشروع مفتوح المصدر. يجب على أي فرد أو منظمة تقوم بتعديل أو تفرع هذا الكود الالتزام بترخيص المصدر المفتوح. هام: إذا قام طرف ثالث بتعديل الكود لتجاوز أو إزالة آليات مصادقة/أمان المستخدم الأصلية وتوزيع مثل هذه الإصدارات، فإن المعدل/الموزع يتحمل المسؤولية الكاملة عن أي عواقب. ننصح بشدة بعدم تجاوز أو العبث بأي آليات للتحقق من الأمان.\n\n4. بيان غير ربحي\nهذا مشروع مفتوح المصدر مجاني تمامًا. لا يقبل المطور التبرعات ولم ينشر أي صفحات للتبرع. لا يسمح البرنامج نفسه بأي رسوم ولا يقدم أي خدمات مدفوعة. يرجى توخي الحذر والحذر من أي عمليات احتيال أو معلومات مضللة تدعي تحصيل رسوم نيابة عن هذا المشروع.",
disclaimerText:
"1. الغرض والقيود\nهذا البرنامج (بما في ذلك الكود والوثائق) مخصص فقط للتعلم الشخصي والبحث والتبادل التقني. يُحظر تمامًا استخدام هذا البرنامج لأي أغراض تجارية أو لأي أنشطة غير قانونية تنتهك القوانين واللوائح المحلية.\n\n2. المسؤولية\nالمطور ليس على علم ولا يملك أي سيطرة على كيفية استخدام المستخدمين لهذا البرنامج. يتحمل المستخدم وحده أي مسؤوليات قانونية أو نزاعات أو أضرار تنشأ عن الاستخدام غير القانوني أو غير السليم لهذا البرنامج (بما في ذلك على سبيل المثال لا الحصر انتهاك حقوق الطبع والنشر). لا يتحمل المطور أي مسؤولية مباشرة أو غير مباشرة أو مشتركة.\n\n3. التعديلات والتوزيع\nهذا المشروع مفتوح المصدر. يجب على أي فرد أو منظمة تقوم بتعديل أو تفرع هذا الكود الالتزام بترخيص المصدر المفتوح. هام: إذا قام طرف ثالث بتعديل الكود لتجاوز أو إزالة آليات مصادقة/أمان المستخدم الأصلية وتوزيع مثل هذه الإصدارات، فإن المعدل/الموزع يتحمل المسؤولية الكاملة عن أي عواقب. ننصح بشدة بعدم تجاوز أو العبث بأي آليات للتحقق من الأمان.\n\n4. بيان غير ربحي\nهذا مشروع مفتوح المصدر مجاني تمامًا. لا يقبل المطور التبرعات ولم ينشر أي صفحات للتبرع. لا يسمح البرنامج نفسه بأي رسوم ولا يقدم أي خدمات مدفوعة. يرجى توخي الحذر والحذر من أي عمليات احتيال أو معلومات مضللة تدعي تحصيل رسوم نيابة عن هذا المشروع.",
};

View File

@@ -45,11 +45,16 @@ export const de = {
websiteName: "Website-Name",
websiteNameHelper: "{current}/{max} Zeichen (Standard: {default})",
infiniteScroll: "Unendliches Scrollen",
infiniteScrollDisabled: "Deaktiviert, wenn unendliches Scrollen aktiviert ist",
infiniteScrollDisabled:
"Deaktiviert, wenn unendliches Scrollen aktiviert ist",
maxVideoColumns: "Maximale Videospalten (Startseite)",
videoColumns: "Videospalten (Startseite)",
columnsCount: "{count} Spalten",
enableLogin: "Anmeldeschutz aktivieren",
allowPasswordLogin: "Passwort-Anmeldung zulassen",
allowPasswordLoginHelper: "Wenn deaktiviert, ist die Passwort-Anmeldung nicht verfügbar. Sie müssen mindestens einen Passkey haben, um die Passwort-Anmeldung zu deaktivieren.",
allowResetPassword: "Passwort zurücksetzen zulassen",
allowResetPasswordHelper: "Wenn deaktiviert, wird die Schaltfläche zum Zurücksetzen des Passworts auf der Anmeldeseite nicht angezeigt und die API zum Zurücksetzen des Passworts wird blockiert.",
password: "Passwort",
enterPassword: "Passwort eingeben",
togglePasswordVisibility: "Passwort sichtbar machen",
@@ -92,7 +97,6 @@ export const de = {
migrationSuccess: "Migration abgeschlossen. Details in der Warnung anzeigen.",
migrationNoData: "Migration abgeschlossen, aber keine Daten gefunden.",
migrationFailed: "Migration fehlgeschlagen",
migrationWarnings: "WARNUNGEN",
migrationErrors: "FEHLER",
itemsMigrated: "Elemente migriert",
@@ -125,8 +129,10 @@ export const de = {
showYoutubeSearch: "YouTube-Suchergebnisse anzeigen",
visitorMode: "Besuchermodus (Nur-Lesen)",
visitorModeReadOnly: "Besuchermodus: Nur-Lesen",
visitorModeDescription: "Nur-Lese-Modus. Ausgeblendete Videos sind für Besucher nicht sichtbar.",
visitorModePasswordPrompt: "Bitte geben Sie das Website-Passwort ein, um die Besuchermodus-Einstellungen zu ändern.",
visitorModeDescription:
"Nur-Lese-Modus. Ausgeblendete Videos sind für Besucher nicht sichtbar.",
visitorModePasswordPrompt:
"Bitte geben Sie das Website-Passwort ein, um die Besuchermodus-Einstellungen zu ändern.",
cleanupTempFilesSuccess: "Erfolgreich {count} temporäre Datei(en) gelöscht.",
cleanupTempFilesFailed: "Fehler beim Bereinigen temporärer Dateien",
@@ -155,12 +161,14 @@ export const de = {
apiUrlHelper: "z.B. https://your-alist-instance.com/api/fs/put",
token: "Token",
publicUrl: "Öffentliche URL",
publicUrlHelper: "Öffentliche Domain für den Dateizugriff (z.B. https://your-cloudflare-tunnel-domain.com). Wenn gesetzt, wird diese anstelle der API-URL für den Dateizugriff verwendet.",
publicUrlHelper:
"Öffentliche Domain für den Dateizugriff (z.B. https://your-cloudflare-tunnel-domain.com). Wenn gesetzt, wird diese anstelle der API-URL für den Dateizugriff verwendet.",
uploadPath: "Upload-Pfad",
cloudDrivePathHelper:
"Verzeichnispfad im Cloud-Speicher, z.B. /mytube-uploads",
scanPaths: "Scan-Pfade",
scanPathsHelper: "Ein Pfad pro Zeile. Videos werden von diesen Pfaden gescannt. Wenn leer, wird der Upload-Pfad verwendet. Beispiel:\n/a/Filme\n/b/Dokumentationen",
scanPathsHelper:
"Ein Pfad pro Zeile. Videos werden von diesen Pfaden gescannt. Wenn leer, wird der Upload-Pfad verwendet. Beispiel:\n/a/Filme\n/b/Dokumentationen",
cloudDriveNote:
"Nach Aktivierung dieser Funktion werden neu heruntergeladene Videos automatisch in den Cloud-Speicher hochgeladen und lokale Dateien werden gelöscht. Videos werden über einen Proxy aus dem Cloud-Speicher abgespielt.",
cloudScanAdded: "Aus Cloud hinzugefügt",
@@ -168,26 +176,36 @@ export const de = {
testConnection: "Verbindung testen",
sync: "Synchronisieren",
syncToCloud: "Zwei-Wege-Synchronisierung",
syncWarning: "Dieser Vorgang lädt lokale Videos in die Cloud hoch und sucht im Cloud-Speicher nach neuen Dateien. Lokale Dateien werden nach dem Upload gelöscht.",
syncWarning:
"Dieser Vorgang lädt lokale Videos in die Cloud hoch und sucht im Cloud-Speicher nach neuen Dateien. Lokale Dateien werden nach dem Upload gelöscht.",
syncing: "Synchronisiere...",
syncCompleted: "Synchronisation abgeschlossen",
syncFailed: "Synchronisation fehlgeschlagen",
syncReport: "Gesamt: {total} | Hochgeladen: {uploaded} | Fehlgeschlagen: {failed}",
syncReport:
"Gesamt: {total} | Hochgeladen: {uploaded} | Fehlgeschlagen: {failed}",
syncErrors: "Fehler:",
fillApiUrlToken: "Bitte füllen Sie zuerst API-URL und Token aus",
connectionTestSuccess: "Verbindungstest erfolgreich! Einstellungen sind gültig.",
connectionFailedStatus: "Verbindung fehlgeschlagen: Server gab Status {status} zurück",
connectionFailedUrl: "Kann nicht mit Server verbinden. Bitte überprüfen Sie die API-URL.",
authFailed: "Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihr Token.",
connectionTestSuccess:
"Verbindungstest erfolgreich! Einstellungen sind gültig.",
connectionFailedStatus:
"Verbindung fehlgeschlagen: Server gab Status {status} zurück",
connectionFailedUrl:
"Kann nicht mit Server verbinden. Bitte überprüfen Sie die API-URL.",
authFailed:
"Authentifizierung fehlgeschlagen. Bitte überprüfen Sie Ihr Token.",
connectionTestFailed: "Verbindungstest fehlgeschlagen: {error}",
syncFailedMessage: "Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
foundVideosToSync: "{count} Videos mit lokalen Dateien zum Synchronisieren gefunden",
syncFailedMessage:
"Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
foundVideosToSync:
"{count} Videos mit lokalen Dateien zum Synchronisieren gefunden",
uploadingVideo: "Lade hoch: {title}",
clearThumbnailCache: "Lokalen Thumbnail-Cache leeren",
clearing: "Leeren...",
clearThumbnailCacheSuccess: "Thumbnail-Cache erfolgreich geleert. Thumbnails werden beim nächsten Zugriff neu generiert.",
clearThumbnailCacheSuccess:
"Thumbnail-Cache erfolgreich geleert. Thumbnails werden beim nächsten Zugriff neu generiert.",
clearThumbnailCacheError: "Fehler beim Leeren des Thumbnail-Caches",
clearThumbnailCacheConfirmMessage: "Dies löscht alle lokal zwischengespeicherten Thumbnails für Cloud-Videos. Thumbnails werden beim nächsten Zugriff aus dem Cloud-Speicher neu generiert. Fortfahren?",
clearThumbnailCacheConfirmMessage:
"Dies löscht alle lokal zwischengespeicherten Thumbnails für Cloud-Videos. Thumbnails werden beim nächsten Zugriff aus dem Cloud-Speicher neu generiert. Fortfahren?",
manageContent: "Inhalte Verwalten",
videos: "Videos",
@@ -275,7 +293,8 @@ export const de = {
openInExternalPlayer: "In externem Player öffnen",
playWith: "Abspielen mit...",
deleteAllFilteredVideos: "Alle gefilterten Videos löschen",
confirmDeleteFilteredVideos: "Sind Sie sicher, dass Sie {count} Videos löschen möchten, die nach den ausgewählten Tags gefiltert wurden?",
confirmDeleteFilteredVideos:
"Sind Sie sicher, dass Sie {count} Videos löschen möchten, die nach den ausgewählten Tags gefiltert wurden?",
deleteFilteredVideosSuccess: "Erfolgreich {count} Videos gelöscht.",
deletingVideos: "Videos werden gelöscht...",
signIn: "Anmelden",
@@ -295,10 +314,30 @@ export const de = {
resetPasswordConfirm: "Zurücksetzen",
resetPasswordSuccess:
"Das Passwort wurde zurückgesetzt. Überprüfen Sie die Backend-Protokolle für das neue Passwort.",
resetPasswordDisabledInfo: "Die Passwort-Zurücksetzung ist deaktiviert. Um Ihr Passwort zurückzusetzen, führen Sie den folgenden Befehl im Backend-Verzeichnis aus:\n\nnpm run reset-password\n\nOder:\n\nts-node scripts/reset-password.ts\n\nDies generiert ein neues zufälliges Passwort und aktiviert die Passwort-Anmeldung.",
waitTimeMessage: "Bitte warten Sie {time}, bevor Sie es erneut versuchen.",
tooManyAttempts: "Zu viele fehlgeschlagene Versuche.",
// Passkeys
createPasskey: "Passkey erstellen",
creatingPasskey: "Wird erstellt...",
passkeyCreated: "Passkey erfolgreich erstellt",
passkeyCreationFailed:
"Passkey konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
removePasskeys: "Alle Passkeys entfernen",
removePasskeysTitle: "Alle Passkeys entfernen",
removePasskeysMessage:
"Sind Sie sicher, dass Sie alle Passkeys entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
passkeysRemoved: "Alle Passkeys wurden entfernt",
passkeysRemoveFailed:
"Passkeys konnten nicht entfernt werden. Bitte versuchen Sie es erneut.",
loginWithPasskey: "Mit Passkey anmelden",
authenticating: "Wird authentifiziert...",
passkeyLoginFailed:
"Passkey-Authentifizierung fehlgeschlagen. Bitte versuchen Sie es erneut.",
linkCopied: "Link in die Zwischenablage kopiert",
copyFailed: "Link konnte nicht kopiert werden",
passkeyRequiresHttps: "WebAuthn erfordert HTTPS oder localhost. Bitte greifen Sie über HTTPS auf die Anwendung zu oder verwenden Sie localhost anstelle einer IP-Adresse.",
passkeyWebAuthnNotSupported: "WebAuthn wird in diesem Browser nicht unterstützt. Bitte verwenden Sie einen modernen Browser, der WebAuthn unterstützt.",
loadingCollection: "Sammlung wird geladen...",
collectionNotFound: "Sammlung nicht gefunden",
noVideosInCollection: "Keine Videos in dieser Sammlung.",
@@ -308,7 +347,8 @@ export const de = {
unknownAuthor: "Unbekannt",
noVideosForAuthor: "Keine Videos für diesen Autor gefunden.",
deleteAuthor: "Autor löschen",
deleteAuthorConfirmation: "Sind Sie sicher, dass Sie den Autor {author} löschen möchten? Dies löscht alle Videos dieses Autors.",
deleteAuthorConfirmation:
"Sind Sie sicher, dass Sie den Autor {author} löschen möchten? Dies löscht alle Videos dieses Autors.",
authorDeletedSuccessfully: "Autor erfolgreich gelöscht",
failedToDeleteAuthor: "Fehler beim Löschen des Autors",
deleteCollectionTitle: "Sammlung Löschen",
@@ -334,6 +374,11 @@ export const de = {
unknownDate: "Unbekanntes Datum",
part: "Teil",
collection: "Sammlung",
justNow: "Gerade eben",
hoursAgo: "vor {hours} Stunden",
today: "Heute",
thisWeek: "Diese Woche",
weeksAgo: "vor {weeks} Wochen",
selectVideoFile: "Videodatei Auswählen",
pleaseSelectVideo: "Bitte wählen Sie eine Videodatei aus",
uploadFailed: "Upload fehlgeschlagen",
@@ -346,7 +391,8 @@ export const de = {
authorOrPlaylist: "Autor / Wiedergabeliste",
playlistDetected: "Wiedergabeliste erkannt",
playlistHasVideos: "Diese Wiedergabeliste hat {count} Videos.",
downloadPlaylistAndCreateCollection: "Videos der Wiedergabeliste herunterladen und eine Sammlung dafür erstellen?",
downloadPlaylistAndCreateCollection:
"Videos der Wiedergabeliste herunterladen und eine Sammlung dafür erstellen?",
collectionHasVideos: "Diese Bilibili-Sammlung hat {count} Videos.",
seriesHasVideos: "Diese Bilibili-Serie hat {count} Videos.",
videoHasParts: "Dieses Bilibili-Video hat {count} Teile.",
@@ -418,7 +464,8 @@ export const de = {
subscriptionAlreadyExists: "Sie haben diesen Autor bereits abonniert.",
minutes: "Minuten",
never: "Nie",
downloadAllPreviousVideos: "Alle vorherigen Videos dieses Autors herunterladen",
downloadAllPreviousVideos:
"Alle vorherigen Videos dieses Autors herunterladen",
downloadAllPreviousWarning:
"Warnung: Dies lädt alle vorherigen Videos dieses Autors herunter. Dies kann erheblichen Speicherplatz verbrauchen und könnte Bot-Erkennungsmechanismen auslösen, die zu temporären oder dauerhaften Sperren der Plattform führen können. Verwenden Sie auf eigenes Risiko.",
continuousDownloadTasks: "Kontinuierliche Download-Aufgaben",
@@ -428,14 +475,17 @@ export const de = {
taskStatusCancelled: "Abgebrochen",
downloaded: "Heruntergeladen",
cancelTask: "Aufgabe abbrechen",
confirmCancelTask: "Sind Sie sicher, dass Sie die Download-Aufgabe für {author} abbrechen möchten?",
confirmCancelTask:
"Sind Sie sicher, dass Sie die Download-Aufgabe für {author} abbrechen möchten?",
taskCancelled: "Aufgabe erfolgreich abgebrochen",
deleteTask: "Aufgabe löschen",
confirmDeleteTask: "Sind Sie sicher, dass Sie den Aufgaben-Datensatz für {author} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
confirmDeleteTask:
"Sind Sie sicher, dass Sie den Aufgaben-Datensatz für {author} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
taskDeleted: "Aufgabe erfolgreich gelöscht",
clearFinishedTasks: "Beendete Aufgaben löschen",
tasksCleared: "Beendete Aufgaben erfolgreich gelöscht",
confirmClearFinishedTasks: "Sind Sie sicher, dass Sie alle beendeten Aufgaben (abgeschlossen, abgebrochen) löschen möchten? Dies entfernt sie aus der Liste, löscht aber keine heruntergeladenen Dateien.",
confirmClearFinishedTasks:
"Sind Sie sicher, dass Sie alle beendeten Aufgaben (abgeschlossen, abgebrochen) löschen möchten? Dies entfernt sie aus der Liste, löscht aber keine heruntergeladenen Dateien.",
clear: "Löschen",
// Instruction Page
instructionSection1Title: "1. Download & Aufgabenverwaltung",
@@ -575,7 +625,8 @@ export const de = {
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Cloudflare Tunnel aktivieren",
cloudflaredToken: "Tunnel-Token (Optional)",
cloudflaredTokenHelper: "Fügen Sie hier Ihr Tunnel-Token ein oder lassen Sie es leer, um einen zufälligen Quick Tunnel zu verwenden.",
cloudflaredTokenHelper:
"Fügen Sie hier Ihr Tunnel-Token ein oder lassen Sie es leer, um einen zufälligen Quick Tunnel zu verwenden.",
waitingForUrl: "Warte auf Quick Tunnel URL...",
running: "Läuft",
stopped: "Gestoppt",
@@ -583,36 +634,49 @@ export const de = {
accountTag: "Konto-Tag",
copied: "Kopiert!",
clickToCopy: "Zum Kopieren klicken",
quickTunnelWarning: "Quick Tunnel URLs ändern sich bei jedem Neustart des Tunnels.",
managedInDashboard: "Öffentlicher Hostname wird in Ihrem Cloudflare Zero Trust Dashboard verwaltet.",
failedToDownloadVideo: "Fehler beim Herunterladen des Videos. Bitte versuchen Sie es erneut.",
quickTunnelWarning:
"Quick Tunnel URLs ändern sich bei jedem Neustart des Tunnels.",
managedInDashboard:
"Öffentlicher Hostname wird in Ihrem Cloudflare Zero Trust Dashboard verwaltet.",
failedToDownloadVideo:
"Fehler beim Herunterladen des Videos. Bitte versuchen Sie es erneut.",
failedToDownload: "Fehler beim Herunterladen. Bitte versuchen Sie es erneut.",
playlistDownloadStarted: "Playlist-Download gestartet",
cleanupTempFilesConfirmMessage: "Dadurch werden alle .ytdl- und .part-Dateien im Upload-Verzeichnis dauerhaft gelöscht. Stellen Sie sicher, dass keine Downloads aktiv sind, bevor Sie fortfahren.",
cleanupTempFilesActiveDownloads: "Temporäre Dateien können nicht bereinigt werden, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie zuerst ab.",
cleanupTempFilesConfirmMessage:
"Dadurch werden alle .ytdl- und .part-Dateien im Upload-Verzeichnis dauerhaft gelöscht. Stellen Sie sicher, dass keine Downloads aktiv sind, bevor Sie fortfahren.",
cleanupTempFilesActiveDownloads:
"Temporäre Dateien können nicht bereinigt werden, während Downloads aktiv sind. Bitte warten Sie, bis alle Downloads abgeschlossen sind, oder brechen Sie sie zuerst ab.",
status: "Status",
videoDownloading: "Video wird heruntergeladen",
copyUrl: "URL kopieren",
new: "NEU",
// Task Hooks
taskHooks: 'Aufgaben-Hoks',
taskHooksDescription: 'Führen Sie benutzerdefinierte Shell-Befehle an bestimmten Punkten im Aufgabenlebenszyklus aus. Verfügbare Umgebungsvariablen: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Warnung: Befehle werden mit den Berechtigungen des Servers ausgeführt. Mit Vorsicht verwenden.',
hookTaskBeforeStart: 'Vor Aufgabenstart',
hookTaskBeforeStartHelper: 'Wird ausgeführt, bevor der Download beginnt.',
hookTaskSuccess: 'Aufgabe Erfolgreich',
hookTaskSuccessHelper: 'Wird nach erfolgreichem Download ausgeführt, vor Cloud-Upload/Löschung (wartet auf Abschluss).',
hookTaskFail: 'Aufgabe Fehlgeschlagen',
hookTaskFailHelper: 'Wird ausgeführt, wenn eine Aufgabe fehlschlägt.',
hookTaskCancel: 'Aufgabe Abgebrochen',
hookTaskCancelHelper: 'Wird ausgeführt, wenn eine Aufgabe manuell abgebrochen wird.',
found: 'Gefunden',
notFound: 'Nicht Gesetzt',
deleteHook: 'Hook-Skript Löschen',
confirmDeleteHook: 'Sind Sie sicher, dass Sie dieses Hook-Skript löschen möchten?',
uploadHook: 'Hochladen .sh',
taskHooks: "Aufgaben-Hoks",
taskHooksDescription:
"Führen Sie benutzerdefinierte Shell-Befehle an bestimmten Punkten im Aufgabenlebenszyklus aus. Verfügbare Umgebungsvariablen: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"Warnung: Befehle werden mit den Berechtigungen des Servers ausgeführt. Mit Vorsicht verwenden.",
enterPasswordToUploadHook:
"Bitte geben Sie Ihr Passwort ein, um dieses Hook-Skript hochzuladen.",
riskCommandDetected: "Risikobefehl erkannt: {command}. Upload abgelehnt.",
hookTaskBeforeStart: "Vor Aufgabenstart",
hookTaskBeforeStartHelper: "Wird ausgeführt, bevor der Download beginnt.",
hookTaskSuccess: "Aufgabe Erfolgreich",
hookTaskSuccessHelper:
"Wird nach erfolgreichem Download ausgeführt, vor Cloud-Upload/Löschung (wartet auf Abschluss).",
hookTaskFail: "Aufgabe Fehlgeschlagen",
hookTaskFailHelper: "Wird ausgeführt, wenn eine Aufgabe fehlschlägt.",
hookTaskCancel: "Aufgabe Abgebrochen",
hookTaskCancelHelper:
"Wird ausgeführt, wenn eine Aufgabe manuell abgebrochen wird.",
found: "Gefunden",
notFound: "Nicht Gesetzt",
deleteHook: "Hook-Skript Löschen",
confirmDeleteHook:
"Sind Sie sicher, dass Sie dieses Hook-Skript löschen möchten?",
uploadHook: "Hochladen .sh",
disclaimerTitle: "Haftungsausschluss",
disclaimerText: "1. Zweck und Einschränkungen\nDiese Software (einschließlich Code und Dokumentation) ist ausschließlich für persönliches Lernen, Forschung und technischen Austausch bestimmt. Es ist strengstens untersagt, diese Software für kommerzielle Zwecke oder illegale Aktivitäten zu verwenden, die gegen lokale Gesetze und Vorschriften verstoßen.\n\n2. Haftung\nDer Entwickler hat keine Kontrolle darüber, wie Benutzer diese Software verwenden. Jegliche rechtliche Haftung, Streitigkeiten oder Schäden, die aus der illegalen oder unsachgemäßen Verwendung dieser Software entstehen (einschließlich, aber nicht beschränkt auf Urheberrechtsverletzungen), liegen allein beim Benutzer. Der Entwickler übernimmt keine direkte, indirekte oder gesamtschuldnerische Haftung.\n\n3. Änderungen und Verbreitung\nDieses Projekt ist Open Source. Jede Einzelperson oder Organisation, die diesen Code ändert oder forkt, muss die Open-Source-Lizenz einhalten. Wichtig: Wenn Dritte den Code ändern, um die ursprünglichen Benutzerauthentifizierungs-/Sicherheitsmechanismen zu umgehen oder zu entfernen, und solche Versionen verbreiten, trägt der Modifikator/Verteiler die volle Verantwortung für alle Konsequenzen. Wir raten dringend davon ab, Sicherheitsüberprüfungsmechanismen zu umgehen oder zu manipulieren.\n\n4. Gemeinnützige Erklärung\nDies ist ein komplett kostenloses Open-Source-Projekt. Der Entwickler akzeptiert keine Spenden und hat nie Spendenseiten veröffentlicht. Die Software selbst erlaubt keine Gebühren und bietet keine kostenpflichtigen Dienste an. Bitte seien Sie wachsam und hüten Sie sich vor Betrug oder irreführenden Informationen, die behaupten, Gebühren im Namen dieses Projekts zu erheben.",
disclaimerText:
"1. Zweck und Einschränkungen\nDiese Software (einschließlich Code und Dokumentation) ist ausschließlich für persönliches Lernen, Forschung und technischen Austausch bestimmt. Es ist strengstens untersagt, diese Software für kommerzielle Zwecke oder illegale Aktivitäten zu verwenden, die gegen lokale Gesetze und Vorschriften verstoßen.\n\n2. Haftung\nDer Entwickler hat keine Kontrolle darüber, wie Benutzer diese Software verwenden. Jegliche rechtliche Haftung, Streitigkeiten oder Schäden, die aus der illegalen oder unsachgemäßen Verwendung dieser Software entstehen (einschließlich, aber nicht beschränkt auf Urheberrechtsverletzungen), liegen allein beim Benutzer. Der Entwickler übernimmt keine direkte, indirekte oder gesamtschuldnerische Haftung.\n\n3. Änderungen und Verbreitung\nDieses Projekt ist Open Source. Jede Einzelperson oder Organisation, die diesen Code ändert oder forkt, muss die Open-Source-Lizenz einhalten. Wichtig: Wenn Dritte den Code ändern, um die ursprünglichen Benutzerauthentifizierungs-/Sicherheitsmechanismen zu umgehen oder zu entfernen, und solche Versionen verbreiten, trägt der Modifikator/Verteiler die volle Verantwortung für alle Konsequenzen. Wir raten dringend davon ab, Sicherheitsüberprüfungsmechanismen zu umgehen oder zu manipulieren.\n\n4. Gemeinnützige Erklärung\nDies ist ein komplett kostenloses Open-Source-Projekt. Der Entwickler akzeptiert keine Spenden und hat nie Spendenseiten veröffentlicht. Die Software selbst erlaubt keine Gebühren und bietet keine kostenpflichtigen Dienste an. Bitte seien Sie wachsam und hüten Sie sich vor Betrug oder irreführenden Informationen, die behaupten, Gebühren im Namen dieses Projekts zu erheben.",
};

View File

@@ -52,6 +52,10 @@ export const en = {
videoColumns: "Video Columns (Homepage)",
columnsCount: "{count} Columns",
enableLogin: "Enable Login Protection",
allowPasswordLogin: "Allow Password Login",
allowPasswordLoginHelper: "When disabled, password login is not available. You must have at least one passkey to disable password login.",
allowResetPassword: "Allow Reset Password",
allowResetPasswordHelper: "When disabled, the reset password button will not be shown on the login page and the reset password API will be blocked.",
password: "Password",
enterPassword: "Enter Password",
togglePasswordVisibility: "Toggle password visibility",
@@ -132,6 +136,8 @@ export const en = {
deleteHook: 'Delete Hook Script',
confirmDeleteHook: 'Are you sure you want to delete this hook script?',
uploadHook: 'Upload .sh',
enterPasswordToUploadHook: 'Please enter your password to upload this hook script.',
riskCommandDetected: 'Risk command detected: {command}. Upload rejected.',
cleanupTempFilesActiveDownloads:
"Cannot clean up temporary files while downloads are active. Please wait for all downloads to complete or cancel them first.",
formatFilenamesSuccess:
@@ -322,8 +328,24 @@ export const en = {
resetPasswordConfirm: "Reset",
resetPasswordSuccess:
"Password has been reset. Check backend logs for the new password.",
resetPasswordDisabledInfo: "Password reset is disabled. To reset your password, run the following command in the backend directory:\n\nnpm run reset-password\n\nOr:\n\nts-node scripts/reset-password.ts\n\nThis will generate a new random password and enable password login.",
waitTimeMessage: "Please wait {time} before trying again.",
tooManyAttempts: "Too many failed attempts.",
// Passkeys
createPasskey: "Create Passkey",
creatingPasskey: "Creating...",
passkeyCreated: "Passkey created successfully",
passkeyCreationFailed: "Failed to create passkey. Please try again.",
passkeyWebAuthnNotSupported: "WebAuthn is not supported in this browser. Please use a modern browser that supports WebAuthn.",
passkeyRequiresHttps: "WebAuthn requires HTTPS or localhost. Please access the application via HTTPS or use localhost instead of an IP address.",
removePasskeys: "Remove All Passkeys",
removePasskeysTitle: "Remove All Passkeys",
removePasskeysMessage: "Are you sure you want to remove all passkeys? This action cannot be undone.",
passkeysRemoved: "All passkeys have been removed",
passkeysRemoveFailed: "Failed to remove passkeys. Please try again.",
loginWithPasskey: "Login with Passkey",
authenticating: "Authenticating...",
passkeyLoginFailed: "Passkey authentication failed. Please try again.",
linkCopied: "Link copied to clipboard",
copyFailed: "Failed to copy link",
copyUrl: "Copy URL",
@@ -373,6 +395,11 @@ export const en = {
part: "Part",
collection: "Collection",
new: "NEW",
justNow: "Just now",
hoursAgo: "{hours} hours ago",
today: "Today",
thisWeek: "This week",
weeksAgo: "{weeks} weeks ago",
// Upload Modal
selectVideoFile: "Select Video File",

View File

@@ -56,11 +56,16 @@ export const es = {
websiteName: "Nombre del sitio web",
websiteNameHelper: "{current}/{max} caracteres (Predeterminado: {default})",
infiniteScroll: "Desplazamiento infinito",
infiniteScrollDisabled: "Desactivado cuando el desplazamiento infinito está habilitado",
infiniteScrollDisabled:
"Desactivado cuando el desplazamiento infinito está habilitado",
maxVideoColumns: "Columnas de video máximas (Página de inicio)",
videoColumns: "Columnas de video (Página de inicio)",
columnsCount: "{count} Columnas",
enableLogin: "Habilitar Protección de Inicio de Sesión",
allowPasswordLogin: "Permitir Inicio de Sesión con Contraseña",
allowPasswordLoginHelper: "Cuando está deshabilitado, el inicio de sesión con contraseña no está disponible. Debe tener al menos una clave de acceso para deshabilitar el inicio de sesión con contraseña.",
allowResetPassword: "Permitir Restablecer Contraseña",
allowResetPasswordHelper: "Cuando está deshabilitado, el botón de restablecer contraseña no se mostrará en la página de inicio de sesión y la API de restablecer contraseña será bloqueada.",
password: "Contraseña",
enterPassword: "Introducir contraseña",
togglePasswordVisibility: "Alternar visibilidad de contraseña",
@@ -139,8 +144,10 @@ export const es = {
showYoutubeSearch: "Mostrar resultados de búsqueda de YouTube",
visitorMode: "Modo Visitante (Solo lectura)",
visitorModeReadOnly: "Modo visitante: Solo lectura",
visitorModeDescription: "Modo de solo lectura. Los videos ocultos no serán visibles para los visitantes.",
visitorModePasswordPrompt: "Por favor, introduzca la contraseña del sitio web para cambiar la configuración del modo visitante.",
visitorModeDescription:
"Modo de solo lectura. Los videos ocultos no serán visibles para los visitantes.",
visitorModePasswordPrompt:
"Por favor, introduzca la contraseña del sitio web para cambiar la configuración del modo visitante.",
cleanupTempFilesSuccess:
"Se eliminaron exitosamente {count} archivo(s) temporal(es).",
cleanupTempFilesFailed: "Error al limpiar archivos temporales",
@@ -170,11 +177,13 @@ export const es = {
apiUrlHelper: "ej. https://your-alist-instance.com/api/fs/put",
token: "Token",
publicUrl: "URL Público",
publicUrlHelper: "Dominio público para acceder a archivos (ej. https://your-cloudflare-tunnel-domain.com). Si se establece, se usará en lugar de la URL de la API para acceder a archivos.",
publicUrlHelper:
"Dominio público para acceder a archivos (ej. https://your-cloudflare-tunnel-domain.com). Si se establece, se usará en lugar de la URL de la API para acceder a archivos.",
uploadPath: "Ruta de carga",
cloudDrivePathHelper: "Ruta del directorio en la nube, ej. /mytube-uploads",
scanPaths: "Rutas de escaneo",
scanPathsHelper: "Una ruta por línea. Se escanearán videos de estas rutas. Si está vacío, se usará la ruta de carga. Ejemplo:\n/a/Peliculas\n/b/Documentales",
scanPathsHelper:
"Una ruta por línea. Se escanearán videos de estas rutas. Si está vacío, se usará la ruta de carga. Ejemplo:\n/a/Peliculas\n/b/Documentales",
cloudDriveNote:
"Después de habilitar esta función, los videos recién descargados se subirán automáticamente al almacenamiento en la nube y se eliminarán los archivos locales. Los videos se reproducirán desde el almacenamiento en la nube a través de un proxy.",
cloudScanAdded: "Añadido desde la nube",
@@ -182,26 +191,33 @@ export const es = {
testConnection: "Probar Conexión",
sync: "Sincronizar",
syncToCloud: "Sincronización bidireccional",
syncWarning: "Esta operación subirá videos locales a la nube y buscará nuevos archivos en el almacenamiento en la nube. Los archivos locales se eliminarán después de la carga.",
syncWarning:
"Esta operación subirá videos locales a la nube y buscará nuevos archivos en el almacenamiento en la nube. Los archivos locales se eliminarán después de la carga.",
syncing: "Sincronizando...",
syncCompleted: "Sincronización Completada",
syncFailed: "Sincronización Fallida",
syncReport: "Total: {total} | Cargados: {uploaded} | Fallidos: {failed}",
syncErrors: "Errores:",
fillApiUrlToken: "Por favor complete primero la URL de la API y el Token",
connectionTestSuccess: "¡Prueba de conexión exitosa! La configuración es válida.",
connectionFailedStatus: "Conexión fallida: El servidor devolvió el estado {status}",
connectionFailedUrl: "No se puede conectar al servidor. Por favor verifique la URL de la API.",
connectionTestSuccess:
"¡Prueba de conexión exitosa! La configuración es válida.",
connectionFailedStatus:
"Conexión fallida: El servidor devolvió el estado {status}",
connectionFailedUrl:
"No se puede conectar al servidor. Por favor verifique la URL de la API.",
authFailed: "Autentiación fallida. Por favor verifique su token.",
connectionTestFailed: "Prueba de conexión fallida: {error}",
syncFailedMessage: "Sincronización fallida. Por favor intente de nuevo.",
foundVideosToSync: "Se encontraron {count} videos con archivos locales para sincronizar",
foundVideosToSync:
"Se encontraron {count} videos con archivos locales para sincronizar",
uploadingVideo: "Subiendo: {title}",
clearThumbnailCache: "Borrar caché local de miniaturas",
clearing: "Borrando...",
clearThumbnailCacheSuccess: "Caché de miniaturas borrado con éxito. Las miniaturas se regenerarán la próxima vez que se acceda a ellas.",
clearThumbnailCacheSuccess:
"Caché de miniaturas borrado con éxito. Las miniaturas se regenerarán la próxima vez que se acceda a ellas.",
clearThumbnailCacheError: "Error al borrar el caché de miniaturas",
clearThumbnailCacheConfirmMessage: "Esto borrará todas las miniaturas almacenadas en caché localmente para videos en la nube. Las miniaturas se regenerarán desde el almacenamiento en la nube la próxima vez que se acceda a ellas. ¿Continuar?",
clearThumbnailCacheConfirmMessage:
"Esto borrará todas las miniaturas almacenadas en caché localmente para videos en la nube. Las miniaturas se regenerarán desde el almacenamiento en la nube la próxima vez que se acceda a ellas. ¿Continuar?",
manageContent: "Gestionar Contenido",
videos: "Videos",
@@ -300,7 +316,8 @@ export const es = {
openInExternalPlayer: "Abrir en reproductor externo",
playWith: "Reproducir con...",
deleteAllFilteredVideos: "Eliminar todos los videos filtrados",
confirmDeleteFilteredVideos: "¿Está seguro de que desea eliminar {count} videos filtrados por las etiquetas seleccionadas?",
confirmDeleteFilteredVideos:
"¿Está seguro de que desea eliminar {count} videos filtrados por las etiquetas seleccionadas?",
deleteFilteredVideosSuccess: "Se han eliminado {count} videos con éxito.",
deletingVideos: "Eliminando videos...",
signIn: "Iniciar Sesión",
@@ -320,10 +337,30 @@ export const es = {
resetPasswordConfirm: "Restablecer",
resetPasswordSuccess:
"La contraseña ha sido restablecida. Consulte los registros del backend para obtener la nueva contraseña.",
resetPasswordDisabledInfo: "El restablecimiento de contraseña está deshabilitado. Para restablecer su contraseña, ejecute el siguiente comando en el directorio del backend:\n\nnpm run reset-password\n\nO:\n\nts-node scripts/reset-password.ts\n\nEsto generará una nueva contraseña aleatoria y habilitará el inicio de sesión con contraseña.",
waitTimeMessage: "Por favor espere {time} antes de intentar nuevamente.",
tooManyAttempts: "Demasiados intentos fallidos.",
// Passkeys
createPasskey: "Crear clave de acceso",
creatingPasskey: "Creando...",
passkeyCreated: "Clave de acceso creada exitosamente",
passkeyCreationFailed:
"Error al crear la clave de acceso. Por favor, inténtelo de nuevo.",
removePasskeys: "Eliminar todas las claves de acceso",
removePasskeysTitle: "Eliminar todas las claves de acceso",
removePasskeysMessage:
"¿Está seguro de que desea eliminar todas las claves de acceso? Esta acción no se puede deshacer.",
passkeysRemoved: "Todas las claves de acceso han sido eliminadas",
passkeysRemoveFailed:
"Error al eliminar las claves de acceso. Por favor, inténtelo de nuevo.",
loginWithPasskey: "Iniciar sesión con clave de acceso",
authenticating: "Autenticando...",
passkeyLoginFailed:
"Error en la autenticación con clave de acceso. Por favor, inténtelo de nuevo.",
linkCopied: "Enlace copiado al portapapeles",
copyFailed: "Error al copiar enlace",
passkeyRequiresHttps: "WebAuthn requiere HTTPS o localhost. Por favor, acceda a la aplicación a través de HTTPS o utilice localhost en lugar de una dirección IP.",
passkeyWebAuthnNotSupported: "WebAuthn no es compatible con este navegador. Por favor, utilice un navegador moderno que sea compatible con WebAuthn.",
// Collection Page: "Cargando colección...", collectionNotFound: "Colección no encontrada",
noVideosInCollection: "No hay videos en esta colección.",
@@ -333,7 +370,8 @@ export const es = {
unknownAuthor: "Desconocido",
noVideosForAuthor: "No se encontraron videos para este autor.",
deleteAuthor: "Eliminar Autor",
deleteAuthorConfirmation: "¿Está seguro de que desea eliminar al autor {author}? Esto eliminará todos los videos asociados con este autor.",
deleteAuthorConfirmation:
"¿Está seguro de que desea eliminar al autor {author}? Esto eliminará todos los videos asociados con este autor.",
authorDeletedSuccessfully: "Autor eliminado con éxito",
failedToDeleteAuthor: "Error al eliminar autor",
deleteCollectionTitle: "Eliminar Colección",
@@ -358,6 +396,11 @@ export const es = {
unknownDate: "Fecha desconocida",
part: "Parte",
collection: "Colección",
justNow: "Ahora mismo",
hoursAgo: "Hace {hours} horas",
today: "Hoy",
thisWeek: "Esta semana",
weeksAgo: "Hace {weeks} semanas",
selectVideoFile: "Seleccionar Archivo de Video",
pleaseSelectVideo: "Por favor seleccione un archivo de video",
uploadFailed: "Carga fallida",
@@ -370,7 +413,8 @@ export const es = {
authorOrPlaylist: "Autor / Lista de reproducción",
playlistDetected: "Lista de reproducción detectada",
playlistHasVideos: "Esta lista de reproducción tiene {count} videos.",
downloadPlaylistAndCreateCollection: "¿Descargar videos de la lista de reproducción y crear una colección para ella?",
downloadPlaylistAndCreateCollection:
"¿Descargar videos de la lista de reproducción y crear una colección para ella?",
collectionHasVideos: "Esta colección de Bilibili tiene {count} videos.",
seriesHasVideos: "Esta serie de Bilibili tiene {count} videos.",
videoHasParts: "Este video de Bilibili tiene {count} partes.",
@@ -433,7 +477,8 @@ export const es = {
subscriptionAlreadyExists: "Ya estás suscrito a este autor.",
minutes: "minutos",
never: "Nunca",
downloadAllPreviousVideos: "Descargar todos los videos anteriores de este autor",
downloadAllPreviousVideos:
"Descargar todos los videos anteriores de este autor",
downloadAllPreviousWarning:
"Advertencia: Esto descargará todos los videos anteriores de este autor. Esto puede consumir un espacio de almacenamiento significativo y podría activar mecanismos de detección de bots que pueden resultar en prohibiciones temporales o permanentes de la plataforma. Úselo bajo su propio riesgo.",
continuousDownloadTasks: "Tareas de descarga continua",
@@ -443,14 +488,17 @@ export const es = {
taskStatusCancelled: "Cancelado",
downloaded: "Descargado",
cancelTask: "Cancelar tarea",
confirmCancelTask: "¿Estás seguro de que quieres cancelar la tarea de descarga para {author}?",
confirmCancelTask:
"¿Estás seguro de que quieres cancelar la tarea de descarga para {author}?",
taskCancelled: "Tarea cancelada exitosamente",
deleteTask: "Eliminar tarea",
confirmDeleteTask: "¿Estás seguro de que quieres eliminar el registro de tarea para {author}? Esta acción no se puede deshacer.",
confirmDeleteTask:
"¿Estás seguro de que quieres eliminar el registro de tarea para {author}? Esta acción no se puede deshacer.",
taskDeleted: "Tarea eliminada exitosamente",
clearFinishedTasks: "Borrar tareas finalizadas",
tasksCleared: "Tareas finalizadas borradas con éxito",
confirmClearFinishedTasks: "¿Está seguro de que desea borrar todas las tareas finalizadas (completadas, canceladas)? Esto las eliminará de la lista pero no borrará ningún archivo descargado.",
confirmClearFinishedTasks:
"¿Está seguro de que desea borrar todas las tareas finalizadas (completadas, canceladas)? Esto las eliminará de la lista pero no borrará ningún archivo descargado.",
clear: "Borrar",
// Instruction Page
instructionSection1Title: "1. Descarga y Gestión de Tareas",
@@ -581,7 +629,8 @@ export const es = {
cloudflaredTunnel: "Túnel Cloudflare",
enableCloudflaredTunnel: "Habilitar túnel Cloudflare",
cloudflaredToken: "Token del túnel (Opcional)",
cloudflaredTokenHelper: "Pegue su token de túnel aquí, o déjelo vacío para usar un Quick Tunnel aleatorio.",
cloudflaredTokenHelper:
"Pegue su token de túnel aquí, o déjelo vacío para usar un Quick Tunnel aleatorio.",
waitingForUrl: "Esperando URL de Quick Tunnel...",
running: "Ejecutando",
stopped: "Detenido",
@@ -589,8 +638,10 @@ export const es = {
accountTag: "Etiqueta de cuenta",
copied: "¡Copiado!",
clickToCopy: "Clic para copiar",
quickTunnelWarning: "Las URL de Quick Tunnel cambian cada vez que se reinicia el túnel.",
managedInDashboard: "El nombre de host público se gestiona en su panel de control de Cloudflare Zero Trust.",
quickTunnelWarning:
"Las URL de Quick Tunnel cambian cada vez que se reinicia el túnel.",
managedInDashboard:
"El nombre de host público se gestiona en su panel de control de Cloudflare Zero Trust.",
failedToDownloadVideo: "Error al descargar el video. Inténtalo de nuevo.",
failedToDownload: "Error al descargar. Inténtalo de nuevo.",
playlistDownloadStarted: "Descarga de lista de reproducción iniciada",
@@ -602,24 +653,28 @@ export const es = {
copyUrl: "Copiar URL",
new: "NUEVO",
// Task Hooks
taskHooks: 'Ganchos de Tarea',
taskHooksDescription: 'Ejecute comandos de shell personalizados en puntos específicos del ciclo de vida de la tarea. Variables de entorno disponibles: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Advertencia: Los comandos se ejecutan con los permisos del servidor. Úselo con precaución.',
hookTaskBeforeStart: 'Antes del Inicio de la Tarea',
hookTaskBeforeStartHelper: 'Se ejecuta antes de que comience la descarga.',
hookTaskSuccess: 'Tarea Exitosa',
hookTaskSuccessHelper: 'Se ejecuta después de una descarga exitosa, antes de la carga/eliminación en la nube (espera finalización).',
hookTaskFail: 'Tarea Fallida',
hookTaskFailHelper: 'Se ejecuta cuando falla una tarea.',
hookTaskCancel: 'Tarea Cancelada',
hookTaskCancelHelper: 'Se ejecuta cuando una tarea se cancela manualmente.',
found: 'Encontrado',
notFound: 'No Establecido',
deleteHook: 'Eliminar Script de Gancho',
confirmDeleteHook: '¿Está seguro de que desea eliminar este script de gancho?',
uploadHook: 'Subir .sh',
taskHooks: "Ganchos de Tarea",
taskHooksDescription:
"Ejecute comandos de shell personalizados en puntos específicos del ciclo de vida de la tarea. Variables de entorno disponibles: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"Advertencia: Los comandos se ejecutan con los permisos del servidor. Úselo con precaución.",
hookTaskBeforeStart: "Antes del Inicio de la Tarea",
hookTaskBeforeStartHelper: "Se ejecuta antes de que comience la descarga.",
hookTaskSuccess: "Tarea Exitosa",
hookTaskSuccessHelper:
"Se ejecuta después de una descarga exitosa, antes de la carga/eliminación en la nube (espera finalización).",
hookTaskFail: "Tarea Fallida",
hookTaskFailHelper: "Se ejecuta cuando falla una tarea.",
hookTaskCancel: "Tarea Cancelada",
hookTaskCancelHelper: "Se ejecuta cuando una tarea se cancela manualmente.",
found: "Encontrado",
notFound: "No Establecido",
deleteHook: "Eliminar Script de Gancho",
confirmDeleteHook:
"¿Está seguro de que desea eliminar este script de gancho?",
uploadHook: "Subir .sh",
disclaimerTitle: "Descargo de responsabilidad",
disclaimerText: "1. Propósito y Restricciones\nEste software (incluyendo código y documentación) está destinado únicamente para aprendizaje personal, investigación e intercambio técnico. Está estrictamente prohibido utilizar este software para fines comerciales o actividades ilegales que violen las leyes y regulaciones locales.\n\n2. Responsabilidad\nEl desarrollador desconoce y no tiene control sobre cómo los usuarios utilizan este software. Cualquier responsabilidad legal, disputa o daño derivado del uso ilegal o indebido de este software (incluyendo, entre otros, la infracción de derechos de autor) recaerá únicamente en el usuario. El desarrollador no asume ninguna responsabilidad directa, indirecta o conjunta.\n\n3. Modificaciones y Distribución\nEste proyecto es de código abierto. Cualquier individuo u organización que modifique o bifurque este código debe cumplir con la licencia de código abierto. Importante: Si un tercero modifica el código para eludir o eliminar los mecanismos originales de autenticación/seguridad del usuario y distribuye dichas versiones, el modificador/distribuidor asume toda la responsabilidad por cualquier consecuencia. Desaconsejamos encarecidamente eludir o manipular cualquier mecanismo de verificación de seguridad.\n\n4. Declaración Sin Fines de Lucro\nEste es un proyecto de código abierto completamente gratuito. El desarrollador no acepta donaciones y nunca ha publicado páginas de donación. El software en sí no permite cargos y no ofrece servicios pagos. Por favor, esté atento y tenga cuidado con cualquier estafa o información engañosa que reclame cobrar tarifas en nombre de este proyecto.",
disclaimerText:
"1. Propósito y Restricciones\nEste software (incluyendo código y documentación) está destinado únicamente para aprendizaje personal, investigación e intercambio técnico. Está estrictamente prohibido utilizar este software para fines comerciales o actividades ilegales que violen las leyes y regulaciones locales.\n\n2. Responsabilidad\nEl desarrollador desconoce y no tiene control sobre cómo los usuarios utilizan este software. Cualquier responsabilidad legal, disputa o daño derivado del uso ilegal o indebido de este software (incluyendo, entre otros, la infracción de derechos de autor) recaerá únicamente en el usuario. El desarrollador no asume ninguna responsabilidad directa, indirecta o conjunta.\n\n3. Modificaciones y Distribución\nEste proyecto es de código abierto. Cualquier individuo u organización que modifique o bifurque este código debe cumplir con la licencia de código abierto. Importante: Si un tercero modifica el código para eludir o eliminar los mecanismos originales de autenticación/seguridad del usuario y distribuye dichas versiones, el modificador/distribuidor asume toda la responsabilidad por cualquier consecuencia. Desaconsejamos encarecidamente eludir o manipular cualquier mecanismo de verificación de seguridad.\n\n4. Declaración Sin Fines de Lucro\nEste es un proyecto de código abierto completamente gratuito. El desarrollador no acepta donaciones y nunca ha publicado páginas de donación. El software en sí no permite cargos y no ofrece servicios pagos. Por favor, esté atento y tenga cuidado con cualquier estafa o información engañosa que reclame cobrar tarifas en nombre de este proyecto.",
};

View File

@@ -55,6 +55,10 @@ export const fr = {
videoColumns: "Colonnes vidéo (Accueil)",
columnsCount: "{count} Colonnes",
enableLogin: "Activer la protection par connexion",
allowPasswordLogin: "Autoriser la connexion par mot de passe",
allowPasswordLoginHelper: "Lorsqu'elle est désactivée, la connexion par mot de passe n'est pas disponible. Vous devez avoir au moins une clé d'accès pour désactiver la connexion par mot de passe.",
allowResetPassword: "Autoriser la réinitialisation du mot de passe",
allowResetPasswordHelper: "Lorsqu'elle est désactivée, le bouton de réinitialisation du mot de passe ne sera pas affiché sur la page de connexion et l'API de réinitialisation du mot de passe sera bloquée.",
password: "Mot de passe",
enterPassword: "Entrez le mot de passe",
togglePasswordVisibility: "Afficher/Masquer le mot de passe",
@@ -208,9 +212,11 @@ export const fr = {
uploadingVideo: "Téléversement : {title}",
clearThumbnailCache: "Vider le cache des miniatures locales",
clearing: "Nettoyage...",
clearThumbnailCacheSuccess: "Cache des miniatures vidé avec succès. Les miniatures seront régénérées lors du prochain accès.",
clearThumbnailCacheSuccess:
"Cache des miniatures vidé avec succès. Les miniatures seront régénérées lors du prochain accès.",
clearThumbnailCacheError: "Échec du vidage du cache des miniatures",
clearThumbnailCacheConfirmMessage: "Cela effacera toutes les miniatures mises en cache localement pour les vidéos cloud. Les miniatures seront régénérées à partir du stockage cloud lors du prochain accès. Continuer ?",
clearThumbnailCacheConfirmMessage:
"Cela effacera toutes les miniatures mises en cache localement pour les vidéos cloud. Les miniatures seront régénérées à partir du stockage cloud lors du prochain accès. Continuer ?",
// Manage
manageContent: "Gérer le contenu",
@@ -331,10 +337,30 @@ export const fr = {
resetPasswordConfirm: "Réinitialiser",
resetPasswordSuccess:
"Le mot de passe a été réinitialisé. Consultez les journaux du backend pour le nouveau mot de passe.",
resetPasswordDisabledInfo: "La réinitialisation du mot de passe est désactivée. Pour réinitialiser votre mot de passe, exécutez la commande suivante dans le répertoire backend :\n\nnpm run reset-password\n\nOu :\n\nts-node scripts/reset-password.ts\n\nCela générera un nouveau mot de passe aléatoire et activera la connexion par mot de passe.",
waitTimeMessage: "Veuillez attendre {time} avant de réessayer.",
tooManyAttempts: "Trop de tentatives échouées.",
// Passkeys
createPasskey: "Créer une clé d'accès",
creatingPasskey: "Création en cours...",
passkeyCreated: "Clé d'accès créée avec succès",
passkeyCreationFailed:
"Échec de la création de la clé d'accès. Veuillez réessayer.",
removePasskeys: "Supprimer toutes les clés d'accès",
removePasskeysTitle: "Supprimer toutes les clés d'accès",
removePasskeysMessage:
"Êtes-vous sûr de vouloir supprimer toutes les clés d'accès ? Cette action ne peut pas être annulée.",
passkeysRemoved: "Toutes les clés d'accès ont été supprimées",
passkeysRemoveFailed:
"Échec de la suppression des clés d'accès. Veuillez réessayer.",
loginWithPasskey: "Se connecter avec une clé d'accès",
authenticating: "Authentification en cours...",
passkeyLoginFailed:
"Échec de l'authentification par clé d'accès. Veuillez réessayer.",
linkCopied: "Lien copié dans le presse-papiers",
copyFailed: "Échec de la copie du lien",
passkeyRequiresHttps: "WebAuthn nécessite HTTPS ou localhost. Veuillez accéder à l'application via HTTPS ou utiliser localhost au lieu d'une adresse IP.",
passkeyWebAuthnNotSupported: "WebAuthn n'est pas supporté par ce navigateur. Veuillez utiliser un navigateur moderne qui supporte WebAuthn.",
// Collection Page
loadingCollection: "Chargement de la collection...",
@@ -381,6 +407,11 @@ export const fr = {
unknownDate: "Date inconnue",
part: "Partie",
collection: "Collection",
justNow: "À l'instant",
hoursAgo: "Il y a {hours} heures",
today: "Aujourd'hui",
thisWeek: "Cette semaine",
weeksAgo: "Il y a {weeks} semaines",
// Upload Modal
selectVideoFile: "Sélectionner un fichier vidéo",
@@ -397,7 +428,8 @@ export const fr = {
authorOrPlaylist: "Auteur / Playlist",
playlistDetected: "Playlist détectée",
playlistHasVideos: "Cette playlist contient {count} vidéos.",
downloadPlaylistAndCreateCollection: "Télécharger les vidéos de la playlist et créer une collection pour celle-ci ?",
downloadPlaylistAndCreateCollection:
"Télécharger les vidéos de la playlist et créer une collection pour celle-ci ?",
collectionHasVideos: "Cette collection Bilibili contient {count} vidéos.",
seriesHasVideos: "Cette série Bilibili contient {count} vidéos.",
videoHasParts: "Cette vidéo Bilibili contient {count} parties.",
@@ -484,7 +516,8 @@ export const fr = {
taskDeleted: "Tâche supprimée avec succès",
clearFinishedTasks: "Effacer les tâches terminées",
tasksCleared: "Tâches terminées effacées avec succès",
confirmClearFinishedTasks: "Êtes-vous sûr de vouloir effacer toutes les tâches terminées (complétées, annulées) ? Cela les supprimera de la liste mais ne supprimera aucun fichier téléchargé.",
confirmClearFinishedTasks:
"Êtes-vous sûr de vouloir effacer toutes les tâches terminées (complétées, annulées) ? Cela les supprimera de la liste mais ne supprimera aucun fichier téléchargé.",
clear: "Effacer",
// Instruction Page
instructionSection1Title: "1. Téléchargement et Gestion des Tâches",
@@ -628,7 +661,8 @@ export const fr = {
cloudflaredTunnel: "Tunnel Cloudflare",
enableCloudflaredTunnel: "Activer le tunnel Cloudflare",
cloudflaredToken: "Jeton de tunnel (Optionnel)",
cloudflaredTokenHelper: "Collez votre jeton de tunnel ici, ou laissez vide pour utiliser un Quick Tunnel aléatoire.",
cloudflaredTokenHelper:
"Collez votre jeton de tunnel ici, ou laissez vide pour utiliser un Quick Tunnel aléatoire.",
waitingForUrl: "En attente de l'URL Quick Tunnel...",
running: "En cours",
stopped: "Arrêté",
@@ -636,32 +670,43 @@ export const fr = {
accountTag: "Tag de compte",
copied: "Copié !",
clickToCopy: "Cliquer pour copier",
quickTunnelWarning: "Les URL Quick Tunnel changent à chaque redémarrage du tunnel.",
managedInDashboard: "Le nom d'hôte public est géré dans votre tableau de bord Cloudflare Zero Trust.",
failedToDownloadVideo: "Échec du téléchargement de la vidéo. Veuillez réessayer.",
quickTunnelWarning:
"Les URL Quick Tunnel changent à chaque redémarrage du tunnel.",
managedInDashboard:
"Le nom d'hôte public est géré dans votre tableau de bord Cloudflare Zero Trust.",
failedToDownloadVideo:
"Échec du téléchargement de la vidéo. Veuillez réessayer.",
failedToDownload: "Échec du téléchargement. Veuillez réessayer.",
playlistDownloadStarted: "Téléchargement de la playlist commencé",
copyUrl: "Copier l'URL",
new: "NOUVEAU",
// Task Hooks
taskHooks: 'Crochets de Tâche',
taskHooksDescription: 'Exécutez des commandes shell personnalisées à des points spécifiques du cycle de vie de la tâche. Variables d\'environnement disponibles : MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Avertissement : Les commandes s\'exécutent avec les permissions du serveur. À utiliser avec prudence.',
hookTaskBeforeStart: 'Avant le Début de la Tâche',
hookTaskBeforeStartHelper: 'S\'exécute avant le début du téléchargement.',
hookTaskSuccess: 'Tâche Réussie',
hookTaskSuccessHelper: 'S\'exécute après un téléchargement réussi, avant le téléchargement/suppression cloud (attend la fin).',
hookTaskFail: 'Tâche Échouée',
hookTaskFailHelper: 'S\'exécute lorsqu\'une tâche échoue.',
hookTaskCancel: 'Tâche Annulée',
hookTaskCancelHelper: 'S\'exécute lorsqu\'une tâche est annulée manuellement.',
found: 'Trouvé',
notFound: 'Non Défini',
deleteHook: 'Supprimer le Script de Crochet',
confirmDeleteHook: 'Êtes-vous sûr de vouloir supprimer ce script de crochet ?',
uploadHook: 'Téléverser .sh',
taskHooks: "Crochets de Tâche",
taskHooksDescription:
"Exécutez des commandes shell personnalisées à des points spécifiques du cycle de vie de la tâche. Variables d'environnement disponibles : MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"Avertissement : Les commandes s'exécutent avec les permissions du serveur. À utiliser avec prudence.",
enterPasswordToUploadHook:
"Veuillez entrer votre mot de passe pour télécharger ce script Hook.",
riskCommandDetected:
"Commande à risque détectée : {command}. Téléchargement rejeté.",
hookTaskBeforeStart: "Avant le Début de la Tâche",
hookTaskBeforeStartHelper: "S'exécute avant le début du téléchargement.",
hookTaskSuccess: "Tâche Réussie",
hookTaskSuccessHelper:
"S'exécute après un téléchargement réussi, avant le téléchargement/suppression cloud (attend la fin).",
hookTaskFail: "Tâche Échouée",
hookTaskFailHelper: "S'exécute lorsqu'une tâche échoue.",
hookTaskCancel: "Tâche Annulée",
hookTaskCancelHelper: "S'exécute lorsqu'une tâche est annulée manuellement.",
found: "Trouvé",
notFound: "Non Défini",
deleteHook: "Supprimer le Script de Crochet",
confirmDeleteHook:
"Êtes-vous sûr de vouloir supprimer ce script de crochet ?",
uploadHook: "Téléverser .sh",
disclaimerTitle: "Avis de non-responsabilité",
disclaimerText: "1. Objectif et Restrictions\nCe logiciel (y compris le code et la documentation) est destiné uniquement à l'apprentissage personnel, à la recherche et à l'échange technique. Il est strictement interdit d'utiliser ce logiciel à des fins commerciales ou pour toute activité illégale violant les lois et réglementations locales.\n\n2. Responsabilité\nLe développeur n'a aucune connaissance et aucun contrôle sur la façon dont les utilisateurs utilisent ce logiciel. Toute responsabilité légale, litige ou dommage découlant de l'utilisation illégale ou inappropriée de ce logiciel (y compris, mais sans s'y limiter, la violation du droit d'auteur) sera à la charge exclusive de l'utilisateur. Le développeur n'assume aucune responsabilité directe, indirecte ou conjointe.\n\n3. Modifications et Distribution\nCe projet est open source. Tout individu ou organisation modifiant ou forkant ce code doit se conformer à la licence open source. Important : Si un tiers modifie le code pour contourner ou supprimer les mécanismes d'authentification/sécurité d'origine de l'utilisateur et distribue de telles versions, le modificateur/distributeur porte l'entière responsabilité de toutes les conséquences. Nous déconseillons fortement de contourner ou d'altérer tout mécanisme de vérification de sécurité.\n\n4. Déclaration à But Non Lucratif\nCeci est un projet open source entièrement gratuit. Le développeur n'accepte pas de dons et n'a jamais publié de pages de dons. Le logiciel lui-même ne permet aucun frais et n'offre aucun service payant. Veuillez être vigilant et vous méfier de toute arnaque ou information trompeuse prétendant percevoir des frais au nom de ce projet.",
disclaimerText:
"1. Objectif et Restrictions\nCe logiciel (y compris le code et la documentation) est destiné uniquement à l'apprentissage personnel, à la recherche et à l'échange technique. Il est strictement interdit d'utiliser ce logiciel à des fins commerciales ou pour toute activité illégale violant les lois et réglementations locales.\n\n2. Responsabilité\nLe développeur n'a aucune connaissance et aucun contrôle sur la façon dont les utilisateurs utilisent ce logiciel. Toute responsabilité légale, litige ou dommage découlant de l'utilisation illégale ou inappropriée de ce logiciel (y compris, mais sans s'y limiter, la violation du droit d'auteur) sera à la charge exclusive de l'utilisateur. Le développeur n'assume aucune responsabilité directe, indirecte ou conjointe.\n\n3. Modifications et Distribution\nCe projet est open source. Tout individu ou organisation modifiant ou forkant ce code doit se conformer à la licence open source. Important : Si un tiers modifie le code pour contourner ou supprimer les mécanismes d'authentification/sécurité d'origine de l'utilisateur et distribue de telles versions, le modificateur/distributeur porte l'entière responsabilité de toutes les conséquences. Nous déconseillons fortement de contourner ou d'altérer tout mécanisme de vérification de sécurité.\n\n4. Déclaration à But Non Lucratif\nCeci est un projet open source entièrement gratuit. Le développeur n'accepte pas de dons et n'a jamais publié de pages de dons. Le logiciel lui-même ne permet aucun frais et n'offre aucun service payant. Veuillez être vigilant et vous méfier de toute arnaque ou information trompeuse prétendant percevoir des frais au nom de ce projet.",
};

View File

@@ -54,6 +54,10 @@ export const ja = {
videoColumns: "ビデオ列数 (ホームページ)",
columnsCount: "{count} 列",
enableLogin: "ログイン保護を有効にする",
allowPasswordLogin: "パスワードログインを許可",
allowPasswordLoginHelper: "無効にすると、パスワードログインは利用できません。パスワードログインを無効にするには、少なくとも1つのパスキーが必要です。",
allowResetPassword: "パスワードリセットを許可",
allowResetPasswordHelper: "無効にすると、ログインページにパスワードリセットボタンが表示されず、パスワードリセットAPIがブロックされます。",
password: "パスワード",
enterPassword: "パスワードを入力",
togglePasswordVisibility: "パスワードの表示切り替え",
@@ -134,8 +138,10 @@ export const ja = {
showYoutubeSearch: "YouTube検索結果を表示",
visitorMode: "ビジターモード(読み取り専用)",
visitorModeReadOnly: "ビジターモード:読み取り専用",
visitorModeDescription: "読み取り専用モード。非表示の動画は訪問者には表示されません。",
visitorModePasswordPrompt: "ビジターモードの設定を変更するには、ウェブサイトのパスワードを入力してください。",
visitorModeDescription:
"読み取り専用モード。非表示の動画は訪問者には表示されません。",
visitorModePasswordPrompt:
"ビジターモードの設定を変更するには、ウェブサイトのパスワードを入力してください。",
cleanupTempFilesSuccess: "{count}個の一時ファイルを正常に削除しました。",
cleanupTempFilesFailed: "一時ファイルのクリーンアップに失敗しました",
@@ -164,12 +170,14 @@ export const ja = {
apiUrlHelper: "例: https://your-alist-instance.com/api/fs/put",
token: "トークン",
publicUrl: "公開URL",
publicUrlHelper: "ファイルにアクセスするための公開ドメイン(例: https://your-cloudflare-tunnel-domain.com。設定されている場合、ファイルアクセスにはAPI URLの代わりにこれが使用されます。",
publicUrlHelper:
"ファイルにアクセスするための公開ドメイン(例: https://your-cloudflare-tunnel-domain.com。設定されている場合、ファイルアクセスにはAPI URLの代わりにこれが使用されます。",
uploadPath: "アップロードパス",
cloudDrivePathHelper:
"クラウドドライブ内のディレクトリパス、例: /mytube-uploads",
scanPaths: "スキャンパス",
scanPathsHelper: "1行に1つのパスを入力してください。これらのパスから動画がスキャンされます。空の場合はアップロードパスが使用されます。例\n/a/映画\n/b/ドキュメンタリー",
scanPathsHelper:
"1行に1つのパスを入力してください。これらのパスから動画がスキャンされます。空の場合はアップロードパスが使用されます。例\n/a/映画\n/b/ドキュメンタリー",
cloudDriveNote:
"この機能を有効にすると、新しくダウンロードされた動画は自動的にクラウドストレージにアップロードされ、ローカルファイルは削除されます。動画はプロキシ経由でクラウドストレージから再生されます。",
cloudScanAdded: "クラウドから追加",
@@ -177,7 +185,8 @@ export const ja = {
testConnection: "接続をテスト",
sync: "同期",
syncToCloud: "双方向同期",
syncWarning: "この操作はローカル動画をクラウドにアップロードし、クラウド上の新しいファイルをスキャンします。アップロード後、ローカルファイルは削除されます。",
syncWarning:
"この操作はローカル動画をクラウドにアップロードし、クラウド上の新しいファイルをスキャンします。アップロード後、ローカルファイルは削除されます。",
syncing: "同期中...",
syncCompleted: "同期完了",
syncFailed: "同期に失敗しました",
@@ -185,18 +194,22 @@ export const ja = {
syncErrors: "エラー:",
fillApiUrlToken: "API URLとトークンを入力してください",
connectionTestSuccess: "接続テスト成功!設定は有効です。",
connectionFailedStatus: "接続失敗:サーバーがステータス {status} を返しました",
connectionFailedStatus:
"接続失敗:サーバーがステータス {status} を返しました",
connectionFailedUrl: "サーバーに接続できません。API URLを確認してください。",
authFailed: "認証に失敗しました。トークンを確認してください。",
connectionTestFailed: "接続テスト失敗:{error}",
syncFailedMessage: "同期に失敗しました。もう一度お試しください。",
foundVideosToSync: "同期するローカルファイルを持つ動画が {count} 件見つかりました",
foundVideosToSync:
"同期するローカルファイルを持つ動画が {count} 件見つかりました",
uploadingVideo: "アップロード中: {title}",
clearThumbnailCache: "サムネイルのローカルキャッシュをクリア",
clearing: "クリア中...",
clearThumbnailCacheSuccess: "サムネイルキャッシュが正常にクリアされました。サムネイルは次回のアクセス時に再生成されます。",
clearThumbnailCacheSuccess:
"サムネイルキャッシュが正常にクリアされました。サムネイルは次回のアクセス時に再生成されます。",
clearThumbnailCacheError: "サムネイルキャッシュのクリアに失敗しました",
clearThumbnailCacheConfirmMessage: "クラウド動画用にローカルにキャッシュされたすべてのサムネイルをクリアします。サムネイルは次回のアクセス時にクラウドストレージから再生成されます。続行しますか?",
clearThumbnailCacheConfirmMessage:
"クラウド動画用にローカルにキャッシュされたすべてのサムネイルをクリアします。サムネイルは次回のアクセス時にクラウドストレージから再生成されます。続行しますか?",
// Manage
manageContent: "コンテンツの管理",
@@ -285,7 +298,8 @@ export const ja = {
openInExternalPlayer: "外部プレーヤーで開く",
playWith: "で再生...",
deleteAllFilteredVideos: "フィルタリングされた動画をすべて削除",
confirmDeleteFilteredVideos: "選択されたタグでフィルタリングされた {count} 本の動画を削除してもよろしいですか?",
confirmDeleteFilteredVideos:
"選択されたタグでフィルタリングされた {count} 本の動画を削除してもよろしいですか?",
deleteFilteredVideosSuccess: "{count} 本の動画を削除しました。",
deletingVideos: "動画を削除中...",
@@ -307,10 +321,29 @@ export const ja = {
resetPasswordConfirm: "リセット",
resetPasswordSuccess:
"パスワードがリセットされました。新しいパスワードについては、バックエンドログを確認してください。",
resetPasswordDisabledInfo: "パスワードリセットは無効になっています。パスワードをリセットするには、バックエンドディレクトリで次のコマンドを実行してください:\n\nnpm run reset-password\n\nまたは\n\nts-node scripts/reset-password.ts\n\nこれにより、新しいランダムパスワードが生成され、パスワードログインが有効になります。",
waitTimeMessage: "再試行する前に {time} お待ちください。",
tooManyAttempts: "失敗した試行が多すぎます。",
// Passkeys
createPasskey: "パスキーを作成",
creatingPasskey: "作成中...",
passkeyCreated: "パスキーが正常に作成されました",
passkeyCreationFailed:
"パスキーの作成に失敗しました。もう一度お試しください。",
removePasskeys: "すべてのパスキーを削除",
removePasskeysTitle: "すべてのパスキーを削除",
removePasskeysMessage:
"すべてのパスキーを削除してもよろしいですか?この操作は元に戻せません。",
passkeysRemoved: "すべてのパスキーが削除されました",
passkeysRemoveFailed:
"パスキーの削除に失敗しました。もう一度お試しください。",
loginWithPasskey: "パスキーでログイン",
authenticating: "認証中...",
passkeyLoginFailed: "パスキー認証に失敗しました。もう一度お試しください。",
linkCopied: "リンクをクリップボードにコピーしました",
copyFailed: "リンクのコピーに失敗しました",
passkeyRequiresHttps: "WebAuthnにはHTTPSまたはlocalhostが必要です。HTTPS経由でアプリケーションにアクセスするか、IPアドレスの代わりにlocalhostを使用してください。",
passkeyWebAuthnNotSupported: "このブラウザはWebAuthnをサポートしていません。WebAuthnをサポートする最新のブラウザを使用してください。",
// Collection Page
loadingCollection: "コレクションを読み込み中...",
@@ -359,6 +392,11 @@ export const ja = {
unknownDate: "不明な日付",
part: "パート",
collection: "コレクション",
justNow: "たった今",
hoursAgo: "{hours}時間前",
today: "今日",
thisWeek: "今週",
weeksAgo: "{weeks}週間前",
// Upload Modal
selectVideoFile: "動画ファイルを選択",
@@ -375,7 +413,8 @@ export const ja = {
authorOrPlaylist: "作者 / 再生リスト",
playlistDetected: "プレイリストが検出されました",
playlistHasVideos: "このプレイリストには{count}本の動画があります。",
downloadPlaylistAndCreateCollection: "プレイリストの動画をダウンロードして、コレクションを作成しますか?",
downloadPlaylistAndCreateCollection:
"プレイリストの動画をダウンロードして、コレクションを作成しますか?",
collectionHasVideos:
"このBilibiliコレクションには{count}個の動画があります。",
seriesHasVideos: "このBilibiliシリーズには{count}個の動画があります。",
@@ -449,14 +488,17 @@ export const ja = {
taskStatusCancelled: "キャンセル済み",
downloaded: "ダウンロード済み",
cancelTask: "タスクをキャンセル",
confirmCancelTask: "{author} のダウンロードタスクをキャンセルしてもよろしいですか?",
confirmCancelTask:
"{author} のダウンロードタスクをキャンセルしてもよろしいですか?",
taskCancelled: "タスクが正常にキャンセルされました",
deleteTask: "タスクを削除",
confirmDeleteTask: "{author} のタスクレコードを削除してもよろしいですか?この操作は元に戻せません。",
confirmDeleteTask:
"{author} のタスクレコードを削除してもよろしいですか?この操作は元に戻せません。",
taskDeleted: "タスクが正常に削除されました",
clearFinishedTasks: "完了したタスクをクリア",
tasksCleared: "完了したタスクを正常にクリアしました",
confirmClearFinishedTasks: "完了したタスク(完了、キャンセル済み)をすべてクリアしてもよろしいですか?これによりリストからは削除されますが、ダウンロードされたファイルは削除されません。",
confirmClearFinishedTasks:
"完了したタスク(完了、キャンセル済み)をすべてクリアしてもよろしいですか?これによりリストからは削除されますが、ダウンロードされたファイルは削除されません。",
clear: "クリア",
// Instruction Page
instructionSection1Title: "1. ダウンロードとタスク管理",
@@ -595,7 +637,8 @@ export const ja = {
lastBackupDate: "最後のバックアップ日時",
noBackupAvailable: "利用可能なバックアップがありません",
deleteAuthor: "著者を削除",
deleteAuthorConfirmation: "著者 {author} を削除してもよろしいですか?これにより、この著者に関連するすべての動画が削除されます。",
deleteAuthorConfirmation:
"著者 {author} を削除してもよろしいですか?これにより、この著者に関連するすべての動画が削除されます。",
authorDeletedSuccessfully: "著者が正常に削除されました",
failedToDeleteAuthor: "著者の削除に失敗しました",
@@ -603,7 +646,8 @@ export const ja = {
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Cloudflare Tunnelを有効にする",
cloudflaredToken: "トンネルトークン (オプション)",
cloudflaredTokenHelper: "ここにトンネルトークンを貼り付けるか、空のままにしてランダムなQuick Tunnelを使用します。",
cloudflaredTokenHelper:
"ここにトンネルトークンを貼り付けるか、空のままにしてランダムなQuick Tunnelを使用します。",
waitingForUrl: "Quick Tunnel URLを待機中...",
running: "実行中",
stopped: "停止",
@@ -611,9 +655,12 @@ export const ja = {
accountTag: "アカウントタグ",
copied: "コピーしました!",
clickToCopy: "クリックしてコピー",
quickTunnelWarning: "Quick TunnelのURLは、トンネルが再起動するたびに変更されます。",
managedInDashboard: "パブリックホスト名はCloudflare Zero Trustダッシュボードで管理されています。",
failedToDownloadVideo: "動画のダウンロードに失敗しました。もう一度お試しください。",
quickTunnelWarning:
"Quick TunnelのURLは、トンネルが再起動するたびに変更されます。",
managedInDashboard:
"パブリックホスト名はCloudflare Zero Trustダッシュボードで管理されています。",
failedToDownloadVideo:
"動画のダウンロードに失敗しました。もう一度お試しください。",
failedToDownload: "ダウンロードに失敗しました。もう一度お試しください。",
playlistDownloadStarted: "プレイリストのダウンロードが開始されました",
deleteFailed: "削除に失敗しました",
@@ -623,24 +670,31 @@ export const ja = {
copyUrl: "URLをコピー",
new: "新規",
// Task Hooks
taskHooks: 'タスクフック',
taskHooksDescription: 'タスクライフサイクルの特定のポイントでカスタムシェルコマンドを実行します。利用可能な環境変数: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH。',
taskHooksWarning: '警告: コマンドはサーバーの権限で実行されます。注意して使用してください。',
hookTaskBeforeStart: 'タスク開始前',
hookTaskBeforeStartHelper: 'ダウンロードが始まる前に実行されます。',
hookTaskSuccess: 'タスク成功',
hookTaskSuccessHelper: 'ダウンロード成功後、クラウドアップロード/削除の前に実行されます(完了を待ちます)。',
hookTaskFail: 'タスク失敗',
hookTaskFailHelper: 'タスクが失敗したときに実行されます。',
hookTaskCancel: 'タスクキャンセル',
hookTaskCancelHelper: 'タスクが手動でキャンセルされたときに実行されます。',
found: '見つかりました',
notFound: '未設定',
deleteHook: 'フックスクリプトを削除',
confirmDeleteHook: 'このフックスクリプトを削除してもよろしいですか?',
uploadHook: 'アップロード .sh',
taskHooks: "タスクフック",
taskHooksDescription:
"タスクライフサイクルの特定のポイントでカスタムシェルコマンドを実行します。利用可能な環境変数: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH。",
taskHooksWarning:
"警告: コマンドはサーバーの権限で実行されます。注意して使用してください。",
enterPasswordToUploadHook:
"このフック・スクリプトをアップロードするにはパスワードを入力してください。",
riskCommandDetected:
"危険なコマンドが検出されました: {command}。アップロードは拒否されました。",
hookTaskBeforeStart: "タスク開始前",
hookTaskBeforeStartHelper: "ダウンロードが始まる前に実行されます。",
hookTaskSuccess: "タスク成功",
hookTaskSuccessHelper:
"ダウンロード成功後、クラウドアップロード/削除の前に実行されます(完了を待ちます)。",
hookTaskFail: "タスク失敗",
hookTaskFailHelper: "タスクが失敗したときに実行されます。",
hookTaskCancel: "タスクキャンセル",
hookTaskCancelHelper: "タスクが手動でキャンセルされたときに実行されます。",
found: "見つかりました",
notFound: "未設定",
deleteHook: "フックスクリプトを削除",
confirmDeleteHook: "このフックスクリプトを削除してもよろしいですか?",
uploadHook: "アップロード .sh",
disclaimerTitle: "免責事項",
disclaimerText: "1. 目的と制限\nこのソフトウェアコードおよびドキュメントを含むは、個人の学習、研究、および技術交流のみを目的としています。このソフトウェアを商業目的で使用すること、または地域の法律や規制に違反する違法行為に使用することは固く禁じられています。\n\n2. 責任\n開発者は、ユーザーがこのソフトウェアをどのように使用するかについて認識しておらず、管理もしていません。このソフトウェアの違法または不適切な使用著作権侵害を含むがこれに限定されないから生じる法的責任、紛争、または損害は、ユーザーのみが負担するものとします。開発者は、直接的、間接的、または連帯責任を負いません。\n\n3. 修正と配布\nこのプロジェクトはオープンソースです。このコードを修正またはフォークする個人または組織は、オープンソースライセンスを遵守する必要があります。重要第三者が元のユーザー認証/セキュリティメカニズムを回避または削除するためにコードを修正し、そのようなバージョンを配布する場合、修正者/配布者はすべての結果に対して全責任を負います。セキュリティ検証メカニズムを回避または改ざんすることを強くお勧めしません。\n\n4. 非営利声明\nこれは完全に無料のオープンソースプロジェクトです。開発者は寄付を受け付けておらず、寄付ページを公開したこともありません。ソフトウェア自体は料金を許可しておらず、有料サービスも提供していません。このプロジェクトに代わって料金を徴収すると主張する詐欺や誤解を招く情報には十分ご注意ください。",
disclaimerText:
"1. 目的と制限\nこのソフトウェアコードおよびドキュメントを含むは、個人の学習、研究、および技術交流のみを目的としています。このソフトウェアを商業目的で使用すること、または地域の法律や規制に違反する違法行為に使用することは固く禁じられています。\n\n2. 責任\n開発者は、ユーザーがこのソフトウェアをどのように使用するかについて認識しておらず、管理もしていません。このソフトウェアの違法または不適切な使用著作権侵害を含むがこれに限定されないから生じる法的責任、紛争、または損害は、ユーザーのみが負担するものとします。開発者は、直接的、間接的、または連帯責任を負いません。\n\n3. 修正と配布\nこのプロジェクトはオープンソースです。このコードを修正またはフォークする個人または組織は、オープンソースライセンスを遵守する必要があります。重要第三者が元のユーザー認証/セキュリティメカニズムを回避または削除するためにコードを修正し、そのようなバージョンを配布する場合、修正者/配布者はすべての結果に対して全責任を負います。セキュリティ検証メカニズムを回避または改ざんすることを強くお勧めしません。\n\n4. 非営利声明\nこれは完全に無料のオープンソースプロジェクトです。開発者は寄付を受け付けておらず、寄付ページを公開したこともありません。ソフトウェア自体は料金を許可しておらず、有料サービスも提供していません。このプロジェクトに代わって料金を徴収すると主張する詐欺や誤解を招く情報には十分ご注意ください。",
};

View File

@@ -54,6 +54,10 @@ export const ko = {
videoColumns: "비디오 열 (홈페이지)",
columnsCount: "{count} 열",
enableLogin: "로그인 보호 활성화",
allowPasswordLogin: "비밀번호 로그인 허용",
allowPasswordLoginHelper: "비활성화되면 비밀번호 로그인을 사용할 수 없습니다. 비밀번호 로그인을 비활성화하려면 최소한 하나의 패스키가 있어야 합니다.",
allowResetPassword: "비밀번호 재설정 허용",
allowResetPasswordHelper: "비활성화되면 로그인 페이지에 비밀번호 재설정 버튼이 표시되지 않고 비밀번호 재설정 API가 차단됩니다.",
password: "비밀번호",
enterPassword: "비밀번호 입력",
togglePasswordVisibility: "비밀번호 표시 전환",
@@ -131,8 +135,10 @@ export const ko = {
showYoutubeSearch: "YouTube 검색 결과 표시",
visitorMode: "방문자 모드 (읽기 전용)",
visitorModeReadOnly: "방문자 모드: 읽기 전용",
visitorModeDescription: "읽기 전용 모드. 숨겨진 동영상은 방문자에게 표시되지 않습니다.",
visitorModePasswordPrompt: "방문자 모드 설정을 변경하려면 웹사이트 비밀번호를 입력하세요.",
visitorModeDescription:
"읽기 전용 모드. 숨겨진 동영상은 방문자에게 표시되지 않습니다.",
visitorModePasswordPrompt:
"방문자 모드 설정을 변경하려면 웹사이트 비밀번호를 입력하세요.",
cleanupTempFilesSuccess: "{count}개의 임시 파일을 성공적으로 삭제했습니다.",
cleanupTempFilesFailed: "임시 파일 정리 실패",
@@ -161,12 +167,14 @@ export const ko = {
apiUrlHelper: "예: https://your-alist-instance.com/api/fs/put",
token: "토큰",
publicUrl: "공개 URL",
publicUrlHelper: "파일 액세스를 위한 공개 도메인 (예: https://your-cloudflare-tunnel-domain.com). 설정된 경우 파일 액세스에 API URL 대신 이것이 사용됩니다.",
publicUrlHelper:
"파일 액세스를 위한 공개 도메인 (예: https://your-cloudflare-tunnel-domain.com). 설정된 경우 파일 액세스에 API URL 대신 이것이 사용됩니다.",
uploadPath: "업로드 경로",
cloudDrivePathHelper:
"클라우드 드라이브 내 디렉토리 경로, 예: /mytube-uploads",
scanPaths: "스캔 경로",
scanPathsHelper: "줄당 하나의 경로. 이 경로에서 동영상을 스캔합니다. 비어 있으면 업로드 경로를 사용합니다. 예:\n/a/영화\n/b/다큐멘터리",
scanPathsHelper:
"줄당 하나의 경로. 이 경로에서 동영상을 스캔합니다. 비어 있으면 업로드 경로를 사용합니다. 예:\n/a/영화\n/b/다큐멘터리",
cloudDriveNote:
"이 기능을 활성화한 후 새로 다운로드된 비디오는 자동으로 클라우드 스토리지에 업로드되고 로컬 파일은 삭제됩니다. 비디오는 프록시를 통해 클라우드 스토리지에서 재생됩니다.",
cloudScanAdded: "클라우드에서 추가됨",
@@ -174,7 +182,8 @@ export const ko = {
testConnection: "연결 테스트",
sync: "동기화",
syncToCloud: "양방향 동기화",
syncWarning: "이 작업은 로컬 동영상을 클라우드로 업로드하고 클라우드 저장소에서 새 파일을 검색합니다. 업로드 후 로컬 파일은 삭제됩니다。",
syncWarning:
"이 작업은 로컬 동영상을 클라우드로 업로드하고 클라우드 저장소에서 새 파일을 검색합니다. 업로드 후 로컬 파일은 삭제됩니다。",
syncing: "동기화 중...",
syncCompleted: "동기화 완료",
syncFailed: "동기화 실패",
@@ -191,9 +200,11 @@ export const ko = {
uploadingVideo: "업로드 중: {title}",
clearThumbnailCache: "썸네일 로컬 캐시 지우기",
clearing: "지우는 중...",
clearThumbnailCacheSuccess: "썸네일 캐시가 성공적으로 지워졌습니다. 썸네일은 다음에 액세스할 때 재생성됩니다.",
clearThumbnailCacheSuccess:
"썸네일 캐시가 성공적으로 지워졌습니다. 썸네일은 다음에 액세스할 때 재생성됩니다.",
clearThumbnailCacheError: "썸네일 캐시 지우기 실패",
clearThumbnailCacheConfirmMessage: "이 작업은 클라우드 비디오에 대해 로컬로 캐시된 모든 썸네일을 지웁니다. 썸네일은 다음에 액세스할 때 클라우드 저장소에서 재생성됩니다. 계속하시겠습니까?",
clearThumbnailCacheConfirmMessage:
"이 작업은 클라우드 비디오에 대해 로컬로 캐시된 모든 썸네일을 지웁니다. 썸네일은 다음에 액세스할 때 클라우드 저장소에서 재생성됩니다. 계속하시겠습니까?",
// Manage
manageContent: "콘텐츠 관리",
@@ -282,7 +293,8 @@ export const ko = {
openInExternalPlayer: "외부 플레이어에서 열기",
playWith: "다음으로 재생...",
deleteAllFilteredVideos: "필터링된 모든 동영상 삭제",
confirmDeleteFilteredVideos: "선택한 태그로 필터링된 {count}개의 동영상을 삭제하시겠습니까?",
confirmDeleteFilteredVideos:
"선택한 태그로 필터링된 {count}개의 동영상을 삭제하시겠습니까?",
deleteFilteredVideosSuccess: "{count}개의 동영상을 성공적으로 삭제했습니다.",
deletingVideos: "동영상 삭제 중...",
@@ -304,10 +316,27 @@ export const ko = {
resetPasswordConfirm: "재설정",
resetPasswordSuccess:
"비밀번호가 재설정되었습니다. 새 비밀번호는 백엔드 로그를 확인하세요.",
resetPasswordDisabledInfo: "비밀번호 재설정이 비활성화되어 있습니다. 비밀번호를 재설정하려면 백엔드 디렉토리에서 다음 명령을 실행하세요:\n\nnpm run reset-password\n\n또는:\n\nts-node scripts/reset-password.ts\n\n이렇게 하면 새로운 임의의 비밀번호가 생성되고 비밀번호 로그인이 활성화됩니다.",
waitTimeMessage: "다시 시도하기 전에 {time} 기다려 주세요.",
tooManyAttempts: "실패한 시도가 너무 많습니다.",
// Passkeys
createPasskey: "패스키 만들기",
creatingPasskey: "만드는 중...",
passkeyCreated: "패스키가 성공적으로 생성되었습니다",
passkeyCreationFailed: "패스키 생성에 실패했습니다. 다시 시도해 주세요.",
removePasskeys: "모든 패스키 제거",
removePasskeysTitle: "모든 패스키 제거",
removePasskeysMessage:
"모든 패스키를 제거하시겠습니까? 이 작업은 취소할 수 없습니다.",
passkeysRemoved: "모든 패스키가 제거되었습니다",
passkeysRemoveFailed: "패스키 제거에 실패했습니다. 다시 시도해 주세요.",
loginWithPasskey: "패스키로 로그인",
authenticating: "인증 중...",
passkeyLoginFailed: "패스키 인증에 실패했습니다. 다시 시도해 주세요.",
linkCopied: "링크가 클립보드에 복사되었습니다",
copyFailed: "링크 복사 실패",
passkeyRequiresHttps: "WebAuthn은 HTTPS 또는 localhost가 필요합니다. HTTPS를 통해 애플리케이션에 액세스하거나 IP 주소 대신 localhost를 사용하십시오.",
passkeyWebAuthnNotSupported: "이 브라우저는 WebAuthn을 지원하지 않습니다. WebAuthn을 지원하는 최신 브라우저를 사용하십시오.",
// Collection Page
loadingCollection: "컬렉션 로드 중...",
@@ -356,6 +385,11 @@ export const ko = {
unknownDate: "알 수 없는 날짜",
part: "파트",
collection: "컬렉션",
justNow: "방금",
hoursAgo: "{hours}시간 전",
today: "오늘",
thisWeek: "이번 주",
weeksAgo: "{weeks}주 전",
// Upload Modal
selectVideoFile: "동영상 파일 선택",
@@ -372,7 +406,8 @@ export const ko = {
authorOrPlaylist: "작성자 / 재생 목록",
playlistDetected: "재생 목록 감지됨",
playlistHasVideos: "이 재생 목록에는 {count}개의 동영상이 있습니다.",
downloadPlaylistAndCreateCollection: "재생 목록 동영상을 다운로드하고 컬렉션을 만드시겠습니까?",
downloadPlaylistAndCreateCollection:
"재생 목록 동영상을 다운로드하고 컬렉션을 만드시겠습니까?",
collectionHasVideos: "이 Bilibili 컬렉션에는 {count}개의 동영상이 있습니다.",
seriesHasVideos: "이 Bilibili 시리즈에는 {count}개의 동영상이 있습니다.",
videoHasParts: "이 Bilibili 동영상에는 {count}개의 파트가 있습니다.",
@@ -448,11 +483,13 @@ export const ko = {
confirmCancelTask: "{author}님의 다운로드 작업을 취소하시겠습니까?",
taskCancelled: "작업이 성공적으로 취소되었습니다",
deleteTask: "작업 삭제",
confirmDeleteTask: "{author}님의 작업 기록을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
confirmDeleteTask:
"{author}님의 작업 기록을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
taskDeleted: "작업이 성공적으로 삭제되었습니다",
clearFinishedTasks: "완료된 작업 지우기",
tasksCleared: "완료된 작업이 성공적으로 지워졌습니다",
confirmClearFinishedTasks: "완료된 모든 작업(완료됨, 취소됨)을 지우시겠습니까? 목록에서 제거되지만 다운로드된 파일은 삭제되지 않습니다.",
confirmClearFinishedTasks:
"완료된 모든 작업(완료됨, 취소됨)을 지우시겠습니까? 목록에서 제거되지만 다운로드된 파일은 삭제되지 않습니다.",
clear: "지우기",
// Instruction Page
instructionSection1Title: "1. 다운로드 및 작업 관리",
@@ -586,7 +623,8 @@ export const ko = {
lastBackupDate: "마지막 백업 날짜",
noBackupAvailable: "사용 가능한 백업 없음",
deleteAuthor: "작성자 삭제",
deleteAuthorConfirmation: "작성자 {author}님을 삭제하시겠습니까? 이 작성자와 관련된 모든 동영상이 삭제됩니다.",
deleteAuthorConfirmation:
"작성자 {author}님을 삭제하시겠습니까? 이 작성자와 관련된 모든 동영상이 삭제됩니다.",
authorDeletedSuccessfully: "작성자가 성공적으로 삭제되었습니다",
failedToDeleteAuthor: "작성자 삭제 실패",
@@ -594,7 +632,8 @@ export const ko = {
cloudflaredTunnel: "Cloudflare 터널",
enableCloudflaredTunnel: "Cloudflare 터널 활성화",
cloudflaredToken: "터널 토큰 (선택 사항)",
cloudflaredTokenHelper: "여기에 터널 토큰을 붙여넣거나, 임의의 Quick Tunnel을 사용하려면 비워 두세요.",
cloudflaredTokenHelper:
"여기에 터널 토큰을 붙여넣거나, 임의의 Quick Tunnel을 사용하려면 비워 두세요.",
waitingForUrl: "Quick Tunnel URL 대기 중...",
running: "실행 중",
stopped: "중지됨",
@@ -602,8 +641,10 @@ export const ko = {
accountTag: "계정 태그",
copied: "복사됨!",
clickToCopy: "클릭하여 복사",
quickTunnelWarning: "Quick Tunnel URL은 터널이 다시 시작될 때마다 변경됩니다.",
managedInDashboard: "공개 호스트 이름은 Cloudflare Zero Trust 대시보드에서 관리됩니다.",
quickTunnelWarning:
"Quick Tunnel URL은 터널이 다시 시작될 때마다 변경됩니다.",
managedInDashboard:
"공개 호스트 이름은 Cloudflare Zero Trust 대시보드에서 관리됩니다.",
failedToDownloadVideo: "동영상 다운로드에 실패했습니다. 다시 시도해 주세요.",
failedToDownload: "다운로드에 실패했습니다. 다시 시도해 주세요.",
playlistDownloadStarted: "재생 목록 다운로드가 시작되었습니다",
@@ -611,24 +652,30 @@ export const ko = {
copyUrl: "URL 복사",
new: "신규",
// Task Hooks
taskHooks: '태스크 훅',
taskHooksDescription: '태스크 수명 주기의 특정 지점에서 사용자 지정 셸 명령을 실행합니다. 사용 가능한 환경 변수: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: '경고: 명령은 서버 권한으로 실행니다. 주의해서 사용하십시오.',
hookTaskBeforeStart: '태스크 시작 전',
hookTaskBeforeStartHelper: '다운로드가 시작되기 전에 실행됩니다.',
hookTaskSuccess: '태스크 성공',
hookTaskSuccessHelper: '다운로드 성공 후, 클라우드 업로드/삭제 전에 실행됩니다 (완료 대기).',
hookTaskFail: '태스크 실패',
hookTaskFailHelper: '태스크가 실패할 때 실행됩니다.',
hookTaskCancel: '태스크 취소됨',
hookTaskCancelHelper: '태스크가 수동으로 취소될 때 실행됩니다.',
found: '찾음',
notFound: '설정되지 않음',
deleteHook: '훅 스크립트 삭제',
confirmDeleteHook: '이 훅 스크립트를 삭제하시겠습니까?',
uploadHook: '업로드 .sh',
taskHooks: "태스크 훅",
taskHooksDescription:
"태스크 수명 주기의 특정 지점에서 사용자 지정 셸 명령을 실행니다. 사용 가능한 환경 변수: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"경고: 명령은 서버 권한으로 실행됩니다. 주의해서 사용하십시오.",
enterPasswordToUploadHook:
"이 훅 스크립트를 업로드하려면 비밀번호를 입력하십시오.",
riskCommandDetected: "위험한 명령 감지됨: {command}. 업로드 거부됨.",
hookTaskBeforeStart: "태스크 시작 전",
hookTaskBeforeStartHelper: "다운로드가 시작되기 전에 실행됩니다.",
hookTaskSuccess: "태스크 성공",
hookTaskSuccessHelper:
"다운로드 성공 후, 클라우드 업로드/삭제 전에 실행됩니다 (완료 대기).",
hookTaskFail: "태스크 실패",
hookTaskFailHelper: "태스크가 실패할 때 실행됩니다.",
hookTaskCancel: "태스크 취소됨",
hookTaskCancelHelper: "태스크가 수동으로 취소될 때 실행됩니다.",
found: "찾음",
notFound: "설정되지 않음",
deleteHook: "훅 스크립트 삭제",
confirmDeleteHook: "이 훅 스크립트를 삭제하시겠습니까?",
uploadHook: "업로드 .sh",
disclaimerTitle: "면책 조항",
disclaimerText: "1. 목적 및 제한\n이 소프트웨어(코드 및 문서 포함)는 개인적인 학습, 연구 및 기술 교류만을 목적으로 합니다. 이 소프트웨어를 상업적 목적으로 사용하거나 현지 법률 및 규정을 위반하는 불법 활동에 사용하는 것은 엄격히 금지됩니다.\n\n2. 책임\n개발자는 사용자가 이 소프트웨어를 어떻게 사용하는지 알지 못하며 통제할 수 없습니다. 이 소프트웨어의 불법적 또는 부적절한 사용(저작권 침해를 포함하되 이에 국한되지 않음)으로 인해 발생하는 모든 법적 책임, 분쟁 또는 손해는 전적으로 사용자가 부담해야 합니다. 개발자는 어떠한 직접적, 간접적 또는 공동 책임도 지지 않습니다.\n\n3. 수정 및 배포\n이 프로젝트는 오픈 소스입니다. 이 코드를 수정하거나 포크하는 개인이나 조직은 오픈 소스 라이선스를 준수해야 합니다. 중요: 제3자가 원래의 사용자 인증/보안 메커니즘을 우회하거나 제거하기 위해 코드를 수정하고 이러한 버전을 배포하는 경우, 수정자/배포자는 모든 결과에 대해 전적인 책임을 집니다. 보안 검증 메커니즘을 우회하거나 변조하는 것을 강력히 권장하지 않습니다.\n\n4. 비영리 성명\n이것은 완전히 무료인 오픈 소스 프로젝트입니다. 개발자는 기부를 받지 않으며 기부 페이지를 게시한 적이 없습니다. 소프트웨어 자체는 요금을 부과하지 않으며 유료 서비스를 제공하지 않습니다. 이 프로젝트를 대신하여 수수료를 징수한다고 주장하는 사기나 오해의 소지가 있는 정보에 주의하시기 바랍니다.",
disclaimerText:
"1. 목적 및 제한\n이 소프트웨어(코드 및 문서 포함)는 개인적인 학습, 연구 및 기술 교류만을 목적으로 합니다. 이 소프트웨어를 상업적 목적으로 사용하거나 현지 법률 및 규정을 위반하는 불법 활동에 사용하는 것은 엄격히 금지됩니다.\n\n2. 책임\n개발자는 사용자가 이 소프트웨어를 어떻게 사용하는지 알지 못하며 통제할 수 없습니다. 이 소프트웨어의 불법적 또는 부적절한 사용(저작권 침해를 포함하되 이에 국한되지 않음)으로 인해 발생하는 모든 법적 책임, 분쟁 또는 손해는 전적으로 사용자가 부담해야 합니다. 개발자는 어떠한 직접적, 간접적 또는 공동 책임도 지지 않습니다.\n\n3. 수정 및 배포\n이 프로젝트는 오픈 소스입니다. 이 코드를 수정하거나 포크하는 개인이나 조직은 오픈 소스 라이선스를 준수해야 합니다. 중요: 제3자가 원래의 사용자 인증/보안 메커니즘을 우회하거나 제거하기 위해 코드를 수정하고 이러한 버전을 배포하는 경우, 수정자/배포자는 모든 결과에 대해 전적인 책임을 집니다. 보안 검증 메커니즘을 우회하거나 변조하는 것을 강력히 권장하지 않습니다.\n\n4. 비영리 성명\n이것은 완전히 무료인 오픈 소스 프로젝트입니다. 개발자는 기부를 받지 않으며 기부 페이지를 게시한 적이 없습니다. 소프트웨어 자체는 요금을 부과하지 않으며 유료 서비스를 제공하지 않습니다. 이 프로젝트를 대신하여 수수료를 징수한다고 주장하는 사기나 오해의 소지가 있는 정보에 주의하시기 바랍니다.",
};

View File

@@ -55,6 +55,10 @@ export const pt = {
videoColumns: "Colunas de vídeo (Página inicial)",
columnsCount: "{count} Colunas",
enableLogin: "Ativar Proteção de Login",
allowPasswordLogin: "Permitir Login com Senha",
allowPasswordLoginHelper: "Quando desabilitado, o login com senha não está disponível. Você deve ter pelo menos uma chave de acesso para desabilitar o login com senha.",
allowResetPassword: "Permitir Redefinir Senha",
allowResetPasswordHelper: "Quando desabilitado, o botão de redefinir senha não será exibido na página de login e a API de redefinir senha será bloqueada.",
password: "Senha",
enterPassword: "Digite a senha",
togglePasswordVisibility: "Alternar visibilidade da senha",
@@ -134,8 +138,10 @@ export const pt = {
showYoutubeSearch: "Mostrar resultados de pesquisa do YouTube",
visitorMode: "Modo Visitante (Somente leitura)",
visitorModeReadOnly: "Modo visitante: Somente leitura",
visitorModeDescription: "Modo somente leitura. Vídeos ocultos não serão visíveis para visitantes.",
visitorModePasswordPrompt: "Por favor, digite a senha do site para alterar as configurações do modo visitante.",
visitorModeDescription:
"Modo somente leitura. Vídeos ocultos não serão visíveis para visitantes.",
visitorModePasswordPrompt:
"Por favor, digite a senha do site para alterar as configurações do modo visitante.",
cleanupTempFilesSuccess:
"{count} arquivo(s) temporário(s) excluído(s) com sucesso.",
cleanupTempFilesFailed: "Falha ao limpar arquivos temporários",
@@ -165,11 +171,13 @@ export const pt = {
apiUrlHelper: "ex. https://your-alist-instance.com/api/fs/put",
token: "Token",
publicUrl: "URL Público",
publicUrlHelper: "Domínio público para acessar arquivos (ex. https://your-cloudflare-tunnel-domain.com). Se definido, será usado em vez da URL da API para acessar arquivos.",
publicUrlHelper:
"Domínio público para acessar arquivos (ex. https://your-cloudflare-tunnel-domain.com). Se definido, será usado em vez da URL da API para acessar arquivos.",
uploadPath: "Caminho de upload",
cloudDrivePathHelper: "Caminho do diretório na nuvem, ex. /mytube-uploads",
scanPaths: "Caminhos de Varredura",
scanPathsHelper: "Um caminho por linha. Os vídeos serão verificados a partir desses caminhos. Se vazio, usará o caminho de upload. Exemplo:\n/a/Filmes\n/b/Documentários",
scanPathsHelper:
"Um caminho por linha. Os vídeos serão verificados a partir desses caminhos. Se vazio, usará o caminho de upload. Exemplo:\n/a/Filmes\n/b/Documentários",
cloudDriveNote:
"Após habilitar este recurso, os vídeos recém-baixados serão automaticamente enviados para o armazenamento em nuvem e os arquivos locais serão excluídos. Os vídeos serão reproduzidos do armazenamento em nuvem via proxy.",
cloudScanAdded: "Adicionado da nuvem",
@@ -177,26 +185,33 @@ export const pt = {
testConnection: "Testar Conexão",
sync: "Sincronizar",
syncToCloud: "Sincronização Bidirecional",
syncWarning: "Esta operação fará upload de vídeos locais para a nuvem e verificará se há novos arquivos no armazenamento em nuvem. Os arquivos locais serão excluídos após o upload.",
syncWarning:
"Esta operação fará upload de vídeos locais para a nuvem e verificará se há novos arquivos no armazenamento em nuvem. Os arquivos locais serão excluídos após o upload.",
syncing: "Sincronizando...",
syncCompleted: "Sincronização Concluída",
syncFailed: "Falha na Sincronização",
syncReport: "Total: {total} | Enviados: {uploaded} | Falhos: {failed}",
syncErrors: "Erros:",
fillApiUrlToken: "Por favor, preencha a URL da API e o Token primeiro",
connectionTestSuccess: "Teste de conexão bem-sucedido! As configurações são válidas.",
connectionFailedStatus: "Falha na conexão: O servidor retornou o status {status}",
connectionFailedUrl: "Não é possível conectar ao servidor. Verifique a URL da API.",
connectionTestSuccess:
"Teste de conexão bem-sucedido! As configurações são válidas.",
connectionFailedStatus:
"Falha na conexão: O servidor retornou o status {status}",
connectionFailedUrl:
"Não é possível conectar ao servidor. Verifique a URL da API.",
authFailed: "Falha na autenticação. Verifique seu token.",
connectionTestFailed: "Falha no teste de conexão: {error}",
syncFailedMessage: "Falha na sincronização. Tente novamente.",
foundVideosToSync: "Encontrados {count} vídeos com arquivos locais para sincronizar",
foundVideosToSync:
"Encontrados {count} vídeos com arquivos locais para sincronizar",
uploadingVideo: "Enviando: {title}",
clearThumbnailCache: "Limpar Cache Local de Miniaturas",
clearing: "Limpando...",
clearThumbnailCacheSuccess: "Cache de miniaturas limpo com sucesso. As miniaturas serão regeneradas na próxima vez que forem acessadas.",
clearThumbnailCacheSuccess:
"Cache de miniaturas limpo com sucesso. As miniaturas serão regeneradas na próxima vez que forem acessadas.",
clearThumbnailCacheError: "Falha ao limpar cache de miniaturas",
clearThumbnailCacheConfirmMessage: "Isso limpará todas as miniaturas armazenadas localmente para vídeos na nuvem. As miniaturas serão regeneradas do armazenamento em nuvem na próxima vez que forem acessadas. Continuar?",
clearThumbnailCacheConfirmMessage:
"Isso limpará todas as miniaturas armazenadas localmente para vídeos na nuvem. As miniaturas serão regeneradas do armazenamento em nuvem na próxima vez que forem acessadas. Continuar?",
// Manage
manageContent: "Gerenciar Conteúdo",
@@ -294,7 +309,8 @@ export const pt = {
openInExternalPlayer: "Abrir no player externo",
playWith: "Reproduzir com...",
deleteAllFilteredVideos: "Excluir todos os vídeos filtrados",
confirmDeleteFilteredVideos: "Tem certeza de que deseja excluir {count} vídeos filtrados pelas tags selecionadas?",
confirmDeleteFilteredVideos:
"Tem certeza de que deseja excluir {count} vídeos filtrados pelas tags selecionadas?",
deleteFilteredVideosSuccess: "{count} vídeos excluídos com sucesso.",
deletingVideos: "Excluindo vídeos...",
@@ -316,10 +332,30 @@ export const pt = {
resetPasswordConfirm: "Redefinir",
resetPasswordSuccess:
"A senha foi redefinida. Verifique os logs do backend para a nova senha.",
resetPasswordDisabledInfo: "A redefinição de senha está desabilitada. Para redefinir sua senha, execute o seguinte comando no diretório do backend:\n\nnpm run reset-password\n\nOu:\n\nts-node scripts/reset-password.ts\n\nIsso gerará uma nova senha aleatória e habilitará o login com senha.",
waitTimeMessage: "Por favor, aguarde {time} antes de tentar novamente.",
tooManyAttempts: "Muitas tentativas falharam.",
// Passkeys
createPasskey: "Criar chave de acesso",
creatingPasskey: "Criando...",
passkeyCreated: "Chave de acesso criada com sucesso",
passkeyCreationFailed:
"Falha ao criar chave de acesso. Por favor, tente novamente.",
removePasskeys: "Remover todas as chaves de acesso",
removePasskeysTitle: "Remover todas as chaves de acesso",
removePasskeysMessage:
"Tem certeza de que deseja remover todas as chaves de acesso? Esta ação não pode ser desfeita.",
passkeysRemoved: "Todas as chaves de acesso foram removidas",
passkeysRemoveFailed:
"Falha ao remover chaves de acesso. Por favor, tente novamente.",
loginWithPasskey: "Entrar com chave de acesso",
authenticating: "Autenticando...",
passkeyLoginFailed:
"Falha na autenticação com chave de acesso. Por favor, tente novamente.",
linkCopied: "Link copiado para a área de transferência",
copyFailed: "Falha ao copiar link",
passkeyRequiresHttps: "WebAuthn requer HTTPS ou localhost. Por favor, acesse o aplicativo via HTTPS ou use localhost em vez de um endereço IP.",
passkeyWebAuthnNotSupported: "WebAuthn não é suportado neste navegador. Por favor, use um navegador moderno que suporte WebAuthn.",
// Collection Page
loadingCollection: "Carregando coleção...",
@@ -333,7 +369,8 @@ export const pt = {
unknownAuthor: "Desconhecido",
noVideosForAuthor: "Nenhum vídeo encontrado para este autor.",
deleteAuthor: "Excluir Autor",
deleteAuthorConfirmation: "Tem certeza de que deseja excluir o autor {author}? Isso excluirá todos os vídeos associados a este autor.",
deleteAuthorConfirmation:
"Tem certeza de que deseja excluir o autor {author}? Isso excluirá todos os vídeos associados a este autor.",
authorDeletedSuccessfully: "Autor excluído com sucesso",
failedToDeleteAuthor: "Falha ao excluir autor",
@@ -364,6 +401,11 @@ export const pt = {
unknownDate: "Data desconhecida",
part: "Parte",
collection: "Coleção",
justNow: "Agora mesmo",
hoursAgo: "Há {hours} horas",
today: "Hoje",
thisWeek: "Esta semana",
weeksAgo: "Há {weeks} semanas",
// Upload Modal
selectVideoFile: "Selecionar Arquivo de Vídeo",
@@ -380,7 +422,8 @@ export const pt = {
authorOrPlaylist: "Autor / Lista de reprodução",
playlistDetected: "Lista de reprodução detectada",
playlistHasVideos: "Esta lista de reprodução tem {count} vídeos.",
downloadPlaylistAndCreateCollection: "Baixar vídeos da lista de reprodução e criar uma coleção para ela?",
downloadPlaylistAndCreateCollection:
"Baixar vídeos da lista de reprodução e criar uma coleção para ela?",
collectionHasVideos: "Esta coleção Bilibili tem {count} vídeos.",
previouslyDeletedVideo: "Vídeo Anteriormente Excluído",
previouslyDeleted: "Anteriormente excluído",
@@ -458,14 +501,17 @@ export const pt = {
taskStatusCancelled: "Cancelado",
downloaded: "Baixado",
cancelTask: "Cancelar tarefa",
confirmCancelTask: "Tem certeza de que deseja cancelar a tarefa de download para {author}?",
confirmCancelTask:
"Tem certeza de que deseja cancelar a tarefa de download para {author}?",
taskCancelled: "Tarefa cancelada com sucesso",
deleteTask: "Excluir tarefa",
confirmDeleteTask: "Tem certeza de que deseja excluir o registro da tarefa para {author}? Esta ação não pode ser desfeita.",
confirmDeleteTask:
"Tem certeza de que deseja excluir o registro da tarefa para {author}? Esta ação não pode ser desfeita.",
taskDeleted: "Tarefa excluída com sucesso",
clearFinishedTasks: "Limpar tarefas concluídas",
tasksCleared: "Tarefas concluídas limpas com sucesso",
confirmClearFinishedTasks: "Tem certeza de que deseja limpar todas as tarefas concluídas (concluídas, canceladas)? Isso as removerá da lista, mas não excluirá nenhum arquivo baixado.",
confirmClearFinishedTasks:
"Tem certeza de que deseja limpar todas as tarefas concluídas (concluídas, canceladas)? Isso as removerá da lista, mas não excluirá nenhum arquivo baixado.",
clear: "Limpar",
// Instruction Page
instructionSection1Title: "1. Download e Gerenciamento de Tarefas",
@@ -606,7 +652,8 @@ export const pt = {
cloudflaredTunnel: "Túnel Cloudflare",
enableCloudflaredTunnel: "Habilitar Túnel Cloudflare",
cloudflaredToken: "Token do Túnel (Opcional)",
cloudflaredTokenHelper: "Cole o token do túnel aqui, ou deixe em branco para usar um Túnel Rápido aleatório.",
cloudflaredTokenHelper:
"Cole o token do túnel aqui, ou deixe em branco para usar um Túnel Rápido aleatório.",
waitingForUrl: "Aguardando URL do Túnel Rápido...",
running: "Executando",
stopped: "Parado",
@@ -614,31 +661,40 @@ export const pt = {
accountTag: "Tag da Conta",
copied: "Copiado!",
clickToCopy: "Clique para copiar",
quickTunnelWarning: "URLs de Túnel Rápido mudam toda vez que o túnel é reiniciado.",
managedInDashboard: "O nome do host público é gerenciado no painel Cloudflare Zero Trust.",
quickTunnelWarning:
"URLs de Túnel Rápido mudam toda vez que o túnel é reiniciado.",
managedInDashboard:
"O nome do host público é gerenciado no painel Cloudflare Zero Trust.",
failedToDownloadVideo: "Falha ao baixar o vídeo. Por favor, tente novamente.",
failedToDownload: "Falha ao baixar. Por favor, tente novamente.",
playlistDownloadStarted: "Download da playlist iniciado",
copyUrl: "Copiar URL",
new: "NOVO",
// Task Hooks
taskHooks: 'Ganchos de Tarefa',
taskHooksDescription: 'Execute comandos shell personalizados em pontos específicos do ciclo de vida da tarefa. Variáveis de ambiente disponíveis: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Aviso: Os comandos são executados com as permissões do servidor. Use com cautela.',
hookTaskBeforeStart: 'Antes do Início da Tarefa',
hookTaskBeforeStartHelper: 'Executa antes do download começar.',
hookTaskSuccess: 'Tarefa com Sucesso',
hookTaskSuccessHelper: 'Executa após download bem-sucedido, antes do upload/exclusão na nuvem (aguarda conclusão).',
hookTaskFail: 'Falha na Tarefa',
hookTaskFailHelper: 'Executa quando uma tarefa falha.',
hookTaskCancel: 'Tarefa Cancelada',
hookTaskCancelHelper: 'Executa quando uma tarefa é cancelada manualmente.',
found: 'Encontrado',
notFound: 'Não Definido',
deleteHook: 'Excluir Script de Gancho',
confirmDeleteHook: 'Tem certeza que deseja excluir este script de gancho?',
uploadHook: 'Enviar .sh',
taskHooks: "Ganchos de Tarefa",
taskHooksDescription:
"Execute comandos shell personalizados em pontos específicos do ciclo de vida da tarefa. Variáveis de ambiente disponíveis: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"Aviso: Os comandos são executados com as permissões do servidor. Use com cautela.",
enterPasswordToUploadHook:
"Por favor, digite sua senha para fazer upload deste script Hook.",
riskCommandDetected:
"Comando de risco detectado: {command}. Upload rejeitado.",
hookTaskBeforeStart: "Antes do Início da Tarefa",
hookTaskBeforeStartHelper: "Executa antes do download começar.",
hookTaskSuccess: "Tarefa com Sucesso",
hookTaskSuccessHelper:
"Executa após download bem-sucedido, antes do upload/exclusão na nuvem (aguarda conclusão).",
hookTaskFail: "Falha na Tarefa",
hookTaskFailHelper: "Executa quando uma tarefa falha.",
hookTaskCancel: "Tarefa Cancelada",
hookTaskCancelHelper: "Executa quando uma tarefa é cancelada manualmente.",
found: "Encontrado",
notFound: "Não Definido",
deleteHook: "Excluir Script de Gancho",
confirmDeleteHook: "Tem certeza que deseja excluir este script de gancho?",
uploadHook: "Enviar .sh",
disclaimerTitle: "Isenção de responsabilidade",
disclaimerText: "1. Objetivo e Restrições\nEste software (incluindo código e documentação) destina-se exclusivamente a aprendizagem pessoal, pesquisa e intercâmbio técnico. É estritamente proibido usar este software para fins comerciais ou para quaisquer atividades ilegais que violem as leis e regulamentos locais.\n\n2. Responsabilidade\nO desenvolvedor desconhece e não tem controle sobre como os usuários utilizam este software. Quaisquer responsabilidades legais, disputas ou danos decorrentes do uso ilegal ou impróprio deste software (incluindo, mas não se limitando a violação de direitos autorais) serão de responsabilidade exclusiva do usuário. O desenvolvedor não assume nenhuma responsabilidade direta, indireta ou conjunta.\n\n3. Modificações e Distribuição\nEste projeto é de código aberto. Qualquer indivíduo ou organização que modifique ou faça fork deste código deve cumprir a licença de código aberto. Importante: Se um terceiro modificar o código para contornar ou remover os mecanismos originais de autenticação/segurança do usuário e distribuir tais versões, o modificador/distribuidor assume total responsabilidade por quaisquer consequências. Desaconselhamos fortemente contornar ou adulterar quaisquer mecanismos de verificação de segurança.\n\n4. Declaração Sem Fins Lucrativos\nEste é um projeto de código aberto totalmente gratuito. O desenvolvedor não aceita doações e nunca publicou páginas de doação. O software em si não permite cobranças e não oferece serviços pagos. Por favor, esteja vigilante e cuidado com quaisquer golpes ou informações enganosas que aleguem cobrar taxas em nome deste projeto.",
disclaimerText:
"1. Objetivo e Restrições\nEste software (incluindo código e documentação) destina-se exclusivamente a aprendizagem pessoal, pesquisa e intercâmbio técnico. É estritamente proibido usar este software para fins comerciais ou para quaisquer atividades ilegais que violem as leis e regulamentos locais.\n\n2. Responsabilidade\nO desenvolvedor desconhece e não tem controle sobre como os usuários utilizam este software. Quaisquer responsabilidades legais, disputas ou danos decorrentes do uso ilegal ou impróprio deste software (incluindo, mas não se limitando a violação de direitos autorais) serão de responsabilidade exclusiva do usuário. O desenvolvedor não assume nenhuma responsabilidade direta, indireta ou conjunta.\n\n3. Modificações e Distribuição\nEste projeto é de código aberto. Qualquer indivíduo ou organização que modifique ou faça fork deste código deve cumprir a licença de código aberto. Importante: Se um terceiro modificar o código para contornar ou remover os mecanismos originais de autenticação/segurança do usuário e distribuir tais versões, o modificador/distribuidor assume total responsabilidade por quaisquer consequências. Desaconselhamos fortemente contornar ou adulterar quaisquer mecanismos de verificação de segurança.\n\n4. Declaração Sem Fins Lucrativos\nEste é um projeto de código aberto totalmente gratuito. O desenvolvedor não aceita doações e nunca publicou páginas de doação. O software em si não permite cobranças e não oferece serviços pagos. Por favor, esteja vigilante e cuidado com quaisquer golpes ou informações enganosas que aleguem cobrar taxas em nome deste projeto.",
};

View File

@@ -64,6 +64,10 @@ export const ru = {
videoColumns: "Колонки видео (Главная страница)",
columnsCount: "{count} колонок",
enableLogin: "Включить защиту входа",
allowPasswordLogin: "Разрешить вход по паролю",
allowPasswordLoginHelper: "При отключении вход по паролю недоступен. Для отключения входа по паролю необходимо иметь хотя бы один ключ доступа.",
allowResetPassword: "Разрешить сброс пароля",
allowResetPasswordHelper: "При отключении кнопка сброса пароля не будет отображаться на странице входа, а API сброса пароля будет заблокирована.",
password: "Пароль",
enterPassword: "Введите пароль",
togglePasswordVisibility: "Показать/скрыть пароль",
@@ -143,8 +147,10 @@ export const ru = {
showYoutubeSearch: "Показать результаты поиска YouTube",
visitorMode: "Режим посетителя (Только чтение)",
visitorModeReadOnly: "Режим посетителя: Только чтение",
visitorModeDescription: "Режим только чтения. Скрытые видео не будут видны посетителям.",
visitorModePasswordPrompt: "Пожалуйста, введите пароль веб-сайта для изменения настроек режима посетителя.",
visitorModeDescription:
"Режим только чтения. Скрытые видео не будут видны посетителям.",
visitorModePasswordPrompt:
"Пожалуйста, введите пароль веб-сайта для изменения настроек режима посетителя.",
cleanupTempFilesSuccess: "Успешно удалено {count} временных файлов.",
cleanupTempFilesFailed: "Не удалось очистить временные файлы",
@@ -173,11 +179,13 @@ export const ru = {
apiUrlHelper: "напр. https://your-alist-instance.com/api/fs/put",
token: "Токен",
publicUrl: "Публичный URL",
publicUrlHelper: "Публичный домен для доступа к файлам (напр. https://your-cloudflare-tunnel-domain.com). Если установлен, будет использоваться вместо URL API для доступа к файлам.",
publicUrlHelper:
"Публичный домен для доступа к файлам (напр. https://your-cloudflare-tunnel-domain.com). Если установлен, будет использоваться вместо URL API для доступа к файлам.",
uploadPath: "Путь загрузки",
cloudDrivePathHelper: "Путь к каталогу в облаке, напр. /mytube-uploads",
scanPaths: "Пути сканирования",
scanPathsHelper: "Один путь в строке. Видео будут сканироваться из этих путей. Если пусто, будет использоваться путь загрузки. Пример:\n/a/Фильмы\n/b/Документальные",
scanPathsHelper:
"Один путь в строке. Видео будут сканироваться из этих путей. Если пусто, будет использоваться путь загрузки. Пример:\n/a/Фильмы\n/b/Документальные",
cloudDriveNote:
"После включения этой функции недавно загруженные видео будут автоматически загружены в облачное хранилище, а локальные файлы будут удалены. Видео будут воспроизводиться из облачного хранилища через прокси.",
cloudScanAdded: "Добавлено из облака",
@@ -185,7 +193,8 @@ export const ru = {
testConnection: "Тестировать соединение",
sync: "Синхронизировать",
syncToCloud: "Двусторонняя синхронизация",
syncWarning: "Эта операция загрузит локальные видео в облако и просканирует облачное хранилище на наличие новых файлов. Локальные файлы будут удалены после загрузки.",
syncWarning:
"Эта операция загрузит локальные видео в облако и просканирует облачное хранилище на наличие новых файлов. Локальные файлы будут удалены после загрузки.",
syncing: "Синхронизация...",
syncCompleted: "Синхронизация завершена",
syncFailed: "Ошибка синхронизации",
@@ -194,17 +203,21 @@ export const ru = {
fillApiUrlToken: "Пожалуйста, сначала заполните URL API и токен",
connectionTestSuccess: "Тест соединения прошел успешно! Настройки верны.",
connectionFailedStatus: "Ошибка соединения: Сервер вернул статус {status}",
connectionFailedUrl: "Невозможно подключиться к серверу. Пожалуйста, проверьте URL API.",
connectionFailedUrl:
"Невозможно подключиться к серверу. Пожалуйста, проверьте URL API.",
authFailed: "Ошибка аутентификации. Пожалуйста, проверьте ваш токен.",
connectionTestFailed: "Тест соединения не удался: {error}",
syncFailedMessage: "Ошибка синхронизации. Пожалуйста, попробуйте снова.",
foundVideosToSync: "Найдено {count} видео с локальными файлами для синхронизации",
foundVideosToSync:
"Найдено {count} видео с локальными файлами для синхронизации",
uploadingVideo: "Загрузка: {title}",
clearThumbnailCache: "Очистить локальный кэш миниатюр",
clearing: "Очистка...",
clearThumbnailCacheSuccess: "Кэш миниатюр успешно очищен. Миниатюры будут сгенерированы заново при следующем доступе.",
clearThumbnailCacheSuccess:
"Кэш миниатюр успешно очищен. Миниатюры будут сгенерированы заново при следующем доступе.",
clearThumbnailCacheError: "Не удалось очистить кэш миниатюр",
clearThumbnailCacheConfirmMessage: "Это удалит все локально кэшированные миниатюры для облачных видео. Миниатюры будут сгенерированы заново из облачного хранилища при следующем доступе. Продолжить?",
clearThumbnailCacheConfirmMessage:
"Это удалит все локально кэшированные миниатюры для облачных видео. Миниатюры будут сгенерированы заново из облачного хранилища при следующем доступе. Продолжить?",
// Manage
manageContent: "Управление контентом",
@@ -296,7 +309,8 @@ export const ru = {
openInExternalPlayer: "Открыть во внешнем плеере",
playWith: "Воспроизвести с помощью...",
deleteAllFilteredVideos: "Удалить все отфильтрованные видео",
confirmDeleteFilteredVideos: "Вы уверены, что хотите удалить {count} видео, отфильтрованных по выбранным тегам?",
confirmDeleteFilteredVideos:
"Вы уверены, что хотите удалить {count} видео, отфильтрованных по выбранным тегам?",
deleteFilteredVideosSuccess: "Успешно удалено {count} видео.",
deletingVideos: "Удаление видео...",
@@ -318,10 +332,30 @@ export const ru = {
resetPasswordConfirm: "Сбросить",
resetPasswordSuccess:
"Пароль был сброшен. Проверьте логи бэкенда для нового пароля.",
resetPasswordDisabledInfo: "Сброс пароля отключен. Чтобы сбросить пароль, выполните следующую команду в директории бэкенда:\n\nnpm run reset-password\n\nИли:\n\nts-node scripts/reset-password.ts\n\nЭто сгенерирует новый случайный пароль и включит вход по паролю.",
waitTimeMessage: "Пожалуйста, подождите {time} перед повторной попыткой.",
tooManyAttempts: "Слишком много неудачных попыток.",
// Passkeys
createPasskey: "Создать ключ доступа",
creatingPasskey: "Создание...",
passkeyCreated: "Ключ доступа успешно создан",
passkeyCreationFailed:
"Не удалось создать ключ доступа. Пожалуйста, попробуйте снова.",
removePasskeys: "Удалить все ключи доступа",
removePasskeysTitle: "Удалить все ключи доступа",
removePasskeysMessage:
"Вы уверены, что хотите удалить все ключи доступа? Это действие нельзя отменить.",
passkeysRemoved: "Все ключи доступа удалены",
passkeysRemoveFailed:
"Не удалось удалить ключи доступа. Пожалуйста, попробуйте снова.",
loginWithPasskey: "Войти с помощью ключа доступа",
authenticating: "Аутентификация...",
passkeyLoginFailed:
"Ошибка аутентификации с помощью ключа доступа. Пожалуйста, попробуйте снова.",
linkCopied: "Ссылка скопирована в буфер обмена",
copyFailed: "Не удалось скопировать ссылку",
passkeyRequiresHttps: "WebAuthn требует HTTPS или localhost. Пожалуйста, войдите в приложение через HTTPS или используйте localhost вместо IP-адреса.",
passkeyWebAuthnNotSupported: "WebAuthn не поддерживается в этом браузере. Пожалуйста, используйте современный браузер с поддержкой WebAuthn.",
// Collection Page
loadingCollection: "Загрузка коллекции...",
@@ -343,7 +377,8 @@ export const ru = {
unknownAuthor: "Неизвестно",
noVideosForAuthor: "Видео этого автора не найдены.",
deleteAuthor: "Удалить автора",
deleteAuthorConfirmation: "Вы уверены, что хотите удалить автора {author}? Это удалит все видео, связанные с этим автором.",
deleteAuthorConfirmation:
"Вы уверены, что хотите удалить автора {author}? Это удалит все видео, связанные с этим автором.",
authorDeletedSuccessfully: "Автор успешно удален",
failedToDeleteAuthor: "Не удалось удалить автора",
@@ -374,6 +409,11 @@ export const ru = {
unknownDate: "Неизвестная дата",
part: "Часть",
collection: "Коллекция",
justNow: "Только что",
hoursAgo: "{hours} часов назад",
today: "Сегодня",
thisWeek: "На этой неделе",
weeksAgo: "{weeks} недель назад",
// Upload Modal
selectVideoFile: "Выберите видеофайл",
@@ -390,7 +430,8 @@ export const ru = {
authorOrPlaylist: "Автор / Плейлист",
playlistDetected: "Обнаружен плейлист",
playlistHasVideos: "В этом плейлисте {count} видео.",
downloadPlaylistAndCreateCollection: "Скачать видео из плейлиста и создать для него коллекцию?",
downloadPlaylistAndCreateCollection:
"Скачать видео из плейлиста и создать для него коллекцию?",
collectionHasVideos: "В этой коллекции Bilibili {count} видео.",
seriesHasVideos: "В этой серии Bilibili {count} видео.",
videoHasParts: "В этом видео Bilibili {count} частей.",
@@ -463,14 +504,17 @@ export const ru = {
taskStatusCancelled: "Отменена",
downloaded: "Скачано",
cancelTask: "Отменить задачу",
confirmCancelTask: "Вы уверены, что хотите отменить задачу загрузки для {author}?",
confirmCancelTask:
"Вы уверены, что хотите отменить задачу загрузки для {author}?",
taskCancelled: "Задача успешно отменена",
deleteTask: "Удалить задачу",
confirmDeleteTask: "Вы уверены, что хотите удалить запись задачи для {author}? Это действие нельзя отменить.",
confirmDeleteTask:
"Вы уверены, что хотите удалить запись задачи для {author}? Это действие нельзя отменить.",
taskDeleted: "Задача успешно удалена",
clearFinishedTasks: "Очистить завершенные задачи",
tasksCleared: "Завершенные задачи успешно очищены",
confirmClearFinishedTasks: "Вы уверены, что хотите очистить все завершенные задачи (завершенные, отмененные)? Это удалит их из списка, но не удалит загруженные файлы.",
confirmClearFinishedTasks:
"Вы уверены, что хотите очистить все завершенные задачи (завершенные, отмененные)? Это удалит их из списка, но не удалит загруженные файлы.",
clear: "Очистить",
// Instruction Page
instructionSection1Title: "1. Загрузка и управление задачами",
@@ -600,7 +644,8 @@ export const ru = {
cloudflaredTunnel: "Cloudflare Tunnel",
enableCloudflaredTunnel: "Включить Cloudflare Tunnel",
cloudflaredToken: "Токен туннеля (Необязательно)",
cloudflaredTokenHelper: "Вставьте сюда токен туннеля или оставьте пустым, чтобы использовать случайный быстрый туннель.",
cloudflaredTokenHelper:
"Вставьте сюда токен туннеля или оставьте пустым, чтобы использовать случайный быстрый туннель.",
waitingForUrl: "Ожидание URL быстрого туннеля...",
running: "Запущен",
stopped: "Остановлен",
@@ -608,33 +653,43 @@ export const ru = {
accountTag: "Тег учетной записи",
copied: "Скопировано!",
clickToCopy: "Нажмите, чтобы скопировать",
quickTunnelWarning: "URL быстрых туннелей меняются при каждом перезапуске туннеля.",
managedInDashboard: "Публичное имя хоста управляется в панели управления Cloudflare Zero Trust.",
failedToDownloadVideo: "Не удалось скачать видео. Пожалуйста, попробуйте снова.",
quickTunnelWarning:
"URL быстрых туннелей меняются при каждом перезапуске туннеля.",
managedInDashboard:
"Публичное имя хоста управляется в панели управления Cloudflare Zero Trust.",
failedToDownloadVideo:
"Не удалось скачать видео. Пожалуйста, попробуйте снова.",
failedToDownload: "Не удалось скачать. Пожалуйста, попробуйте снова.",
playlistDownloadStarted: "Скачивание плейлиста началось",
fromYouTube: "С YouTube",
copyUrl: "Копировать URL",
new: "НОВЫЙ",
// Task Hooks
taskHooks: 'Хуки Задач',
taskHooksDescription: 'Выполняйте пользовательские shell-команды в определенные моменты жизненного цикла задачи. Доступные переменные окружения: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.',
taskHooksWarning: 'Предупреждение: Команды выполняются с правами сервера. Используйте с осторожностью.',
hookTaskBeforeStart: 'Перед Началом Задачи',
hookTaskBeforeStartHelper: 'Выполняется перед началом загрузки.',
hookTaskSuccess: 'Успех Задачи',
hookTaskSuccessHelper: 'Выполняется после успешной загрузки, перед облачной загрузкой/удалением (ожидает завершения).',
hookTaskFail: 'Сбой Задачи',
hookTaskFailHelper: 'Выполняется при сбое задачи.',
hookTaskCancel: 'Задача Отменена',
hookTaskCancelHelper: 'Выполняется при ручной отмене задачи.',
found: 'Найдено',
notFound: 'Не Задано',
deleteHook: 'Удалить Скрипт Хука',
confirmDeleteHook: 'Вы уверены, что хотите удалить этот скрипт хука?',
uploadHook: 'Загрузить .sh',
taskHooks: "Хуки Задач",
taskHooksDescription:
"Выполняйте пользовательские shell-команды в определенные моменты жизненного цикла задачи. Доступные переменные окружения: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH.",
taskHooksWarning:
"Предупреждение: Команды выполняются с правами сервера. Используйте с осторожностью.",
enterPasswordToUploadHook:
"Пожалуйста, введите пароль для загрузки этого Hook-скрипта.",
riskCommandDetected:
"Обнаружена опасная команда: {command}. Загрузка отклонена.",
hookTaskBeforeStart: "Перед Началом Задачи",
hookTaskBeforeStartHelper: "Выполняется перед началом загрузки.",
hookTaskSuccess: "Успех Задачи",
hookTaskSuccessHelper:
"Выполняется после успешной загрузки, перед облачной загрузкой/удалением (ожидает завершения).",
hookTaskFail: "Сбой Задачи",
hookTaskFailHelper: "Выполняется при сбое задачи.",
hookTaskCancel: "Задача Отменена",
hookTaskCancelHelper: "Выполняется при ручной отмене задачи.",
found: "Найдено",
notFound: "Не Задано",
deleteHook: "Удалить Скрипт Хука",
confirmDeleteHook: "Вы уверены, что хотите удалить этот скрипт хука?",
uploadHook: "Загрузить .sh",
disclaimerTitle: "Отказ от ответственности",
disclaimerText: "1. Цель и Ограничения\nЭто программное обеспечение (включая код и документацию) предназначено исключительно для личного обучения, исследований и технического обмена. Строго запрещено использовать это программное обеспечение в коммерческих целях или для любой незаконной деятельности, нарушающей местные законы и правила.\n\n2. Ответственность\nРазработчик не знает и не контролирует, как пользователи используют это программное обеспечение. Любая юридическая ответственность, споры или ущерб, возникающие в результате незаконного или ненадлежащего использования этого программного обеспечения (включая, помимо прочего, нарушение авторских прав), возлагаются исключительно на пользователя. Разработчик не несет никакой прямой, косвенной или солидарной ответственности.\n\n3. Модификации и Распространение\nЭтот проект с открытым исходным кодом. Любое физическое лицо или организация, изменяющая или создающая форк этого кода, должна соблюдать лицензию с открытым исходным кодом. Важно: Если третья сторона изменяет код для обхода или удаления оригинальных механизмов аутентификации/безопасности пользователей и распространяет такие версии, модификатор/распространитель несет полную ответственность за любые последствия. Мы настоятельно не рекомендуем обходить или вмешиваться в любые механизмы проверки безопасности.\n\n4. Некоммерческое Заявление\nЭто полностью бесплатный проект с открытым исходным кодом. Разработчик не принимает пожертвования и никогда не публиковал страницы для пожертвований. Сама программа не предусматривает взимания платы и не предлагает платных услуг. Пожалуйста, будьте бдительны и остерегайтесь мошенничества или вводящей в заблуждение информации, утверждающей о сборе средств от имени этого проекта.",
disclaimerText:
"1. Цель и Ограничения\nЭто программное обеспечение (включая код и документацию) предназначено исключительно для личного обучения, исследований и технического обмена. Строго запрещено использовать это программное обеспечение в коммерческих целях или для любой незаконной деятельности, нарушающей местные законы и правила.\n\n2. Ответственность\nРазработчик не знает и не контролирует, как пользователи используют это программное обеспечение. Любая юридическая ответственность, споры или ущерб, возникающие в результате незаконного или ненадлежащего использования этого программного обеспечения (включая, помимо прочего, нарушение авторских прав), возлагаются исключительно на пользователя. Разработчик не несет никакой прямой, косвенной или солидарной ответственности.\n\n3. Модификации и Распространение\nЭтот проект с открытым исходным кодом. Любое физическое лицо или организация, изменяющая или создающая форк этого кода, должна соблюдать лицензию с открытым исходным кодом. Важно: Если третья сторона изменяет код для обхода или удаления оригинальных механизмов аутентификации/безопасности пользователей и распространяет такие версии, модификатор/распространитель несет полную ответственность за любые последствия. Мы настоятельно не рекомендуем обходить или вмешиваться в любые механизмы проверки безопасности.\n\n4. Некоммерческое Заявление\nЭто полностью бесплатный проект с открытым исходным кодом. Разработчик не принимает пожертвования и никогда не публиковал страницы для пожертвований. Сама программа не предусматривает взимания платы и не предлагает платных услуг. Пожалуйста, будьте бдительны и остерегайтесь мошенничества или вводящей в заблуждение информации, утверждающей о сборе средств от имени этого проекта.",
};

View File

@@ -52,6 +52,10 @@ export const zh = {
videoColumns: "视频列数 (主页)",
columnsCount: "{count} 列",
enableLogin: "启用登录保护",
allowPasswordLogin: "允许密码登录",
allowPasswordLoginHelper: "禁用后,密码登录将不可用。要禁用密码登录,您必须至少有一个通行密钥。",
allowResetPassword: "允许重置密码",
allowResetPasswordHelper: "禁用后,登录页面将不显示重置密码按钮,并且重置密码 API 将被阻止。",
password: "密码",
enterPassword: "输入密码",
togglePasswordVisibility: "切换密码可见性",
@@ -300,8 +304,24 @@ export const zh = {
"您确定要重置密码吗当前密码将被重置为一个随机的8位字符串并显示在后端日志中。",
resetPasswordConfirm: "重置",
resetPasswordSuccess: "密码已重置。请查看后端日志以获取新密码。",
resetPasswordDisabledInfo: "密码重置已禁用。要重置密码,请在后端目录运行以下命令:\n\nnpm run reset-password\n\n或\n\nts-node scripts/reset-password.ts\n\n这将生成一个新的随机密码并启用密码登录。",
waitTimeMessage: "请等待 {time} 后再试。",
tooManyAttempts: "失败尝试次数过多。",
// Passkeys
createPasskey: "创建通行密钥",
creatingPasskey: "创建中...",
passkeyCreated: "通行密钥创建成功",
passkeyCreationFailed: "创建通行密钥失败,请重试。",
passkeyWebAuthnNotSupported: "此浏览器不支持 WebAuthn。请使用支持 WebAuthn 的现代浏览器。",
passkeyRequiresHttps: "WebAuthn 需要 HTTPS 或 localhost。请通过 HTTPS 访问应用程序,或使用 localhost 而不是 IP 地址。",
removePasskeys: "删除所有通行密钥",
removePasskeysTitle: "删除所有通行密钥",
removePasskeysMessage: "您确定要删除所有通行密钥吗?此操作无法撤销。",
passkeysRemoved: "所有通行密钥已删除",
passkeysRemoveFailed: "删除通行密钥失败,请重试。",
loginWithPasskey: "使用通行密钥登录",
authenticating: "验证中...",
passkeyLoginFailed: "通行密钥验证失败,请重试。",
linkCopied: "链接已复制到剪贴板",
copyFailed: "复制链接失败",
@@ -357,6 +377,11 @@ export const zh = {
unknownDate: "未知日期",
part: "分P",
collection: "合集",
justNow: "刚刚",
hoursAgo: "{hours}小时前",
today: "今天",
thisWeek: "本周",
weeksAgo: "{weeks}周前",
// Upload Modal
selectVideoFile: "选择视频文件",
@@ -612,6 +637,8 @@ export const zh = {
taskHooks: '任务钩子',
taskHooksDescription: '在任务生命周期的特定时间点执行自定义 Shell 命令。可用环境变量: MYTUBE_TASK_ID, MYTUBE_TASK_TITLE, MYTUBE_SOURCE_URL, MYTUBE_VIDEO_PATH。',
taskHooksWarning: '警告:命令将以服务器权限运行。请谨慎使用。',
enterPasswordToUploadHook: '请输入密码以上传此 Hook 脚本。',
riskCommandDetected: '检测到危险命令:{command}。上传已拒绝。',
hookTaskBeforeStart: '任务开始前',
hookTaskBeforeStartHelper: '在下载开始前执行。',
hookTaskSuccess: '任务成功',

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "mytube",
"version": "1.7.21",
"version": "1.7.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mytube",
"version": "1.7.21",
"version": "1.7.26",
"license": "MIT",
"dependencies": {
"concurrently": "^8.2.2"

View File

@@ -1,6 +1,6 @@
{
"name": "mytube",
"version": "1.7.21",
"version": "1.7.26",
"description": "Multiple platform video downloader and player application",
"main": "index.js",
"scripts": {