feat: clamav tarama sistemi ve hata yönetimi iyileştirmeleri ekle

ClamAV entegrasyonu ile indirilen altyazı dosyalarının otomatik virüs taraması
eklendi. Pipeline tabanlı hata yönetimi sistemi ile hatalar kategorize edilip
daha iyi işleniyor. Türkcealtyazi sağlayıcısı TV dizileri için sezon/bölüm
bazlı eşleştirme ve paket indirme desteği kazandı. Dosya izleyicide olay
çiftleme (deduplication) mekanizması eklendi. Metin kodlaması normalizasyonu
Türkçe karakterler için geliştirildi.
This commit is contained in:
2026-02-16 13:44:42 +03:00
parent d38fc3b390
commit 4606970577
29 changed files with 2609 additions and 100 deletions

View File

@@ -27,6 +27,278 @@
"vitest": "^3.0.5"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"cpu": [
@@ -42,6 +314,159 @@
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"funding": [
@@ -175,6 +600,58 @@
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"cpu": [
@@ -186,10 +663,261 @@
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"cpu": [
@@ -202,6 +930,104 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/chai": {
"version": "5.2.3",
"dev": true,
@@ -890,6 +1716,21 @@
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"license": "MIT",

View File

@@ -14,5 +14,6 @@ export const env = {
mediaMoviePath: process.env.MEDIA_MOVIE_PATH ?? '/media/movie',
enableApiKey: process.env.ENABLE_API_KEY === 'true',
apiKey: process.env.API_KEY ?? '',
watcherDedupWindowMs: Number(process.env.CORE_WATCHER_DEDUP_WINDOW_MS ?? 15000),
isDev: (process.env.NODE_ENV ?? 'development') !== 'production'
};

View File

@@ -1,7 +1,7 @@
import IORedis from 'ioredis';
import { Redis } from 'ioredis';
import { env } from '../config/env.js';
export const redis = new IORedis({
export const redis = new Redis({
host: env.redisHost,
port: env.redisPort,
maxRetriesPerRequest: null

View File

@@ -1,11 +1,17 @@
import { Queue, Worker } from 'bullmq';
import { redis } from '../db/redis.js';
import { env } from '../config/env.js';
export const fileEventsQueue = new Queue('fileEvents', { connection: redis });
export const mediaAnalysisQueue = new Queue('mediaAnalysis', { connection: redis });
export const subtitleFetchQueue = new Queue('subtitleFetch', { connection: redis });
export const finalizeWriteQueue = new Queue('finalizeWrite', { connection: redis });
const connection = {
host: env.redisHost,
port: env.redisPort,
maxRetriesPerRequest: null
};
export const fileEventsQueue = new Queue('fileEvents', { connection });
export const mediaAnalysisQueue = new Queue('mediaAnalysis', { connection });
export const subtitleFetchQueue = new Queue('subtitleFetch', { connection });
export const finalizeWriteQueue = new Queue('finalizeWrite', { connection });
export function createWorker(name: string, processor: any): Worker {
return new Worker(name, processor, { connection: redis, concurrency: 2 });
return new Worker(name, processor, { connection, concurrency: 2 });
}

View File

@@ -3,7 +3,6 @@ import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { env } from '../config/env.js';
import { createJobForPath } from '../workers/pipeline.js';
import { MediaFileModel } from '../models/MediaFile.js';
import { fileEventsQueue } from '../queues/queues.js';
export async function debugRoutes(app: FastifyInstance): Promise<void> {
@@ -17,11 +16,10 @@ export async function debugRoutes(app: FastifyInstance): Promise<void> {
return reply.status(400).send({ error: 'Path does not exist in container' });
}
const jobId = await createJobForPath(body.path, body.kind);
const media = await MediaFileModel.findOne({ path: body.path }).lean();
if (!media) return reply.status(500).send({ error: 'media not persisted' });
await fileEventsQueue.add('debug', { jobId, mediaFileId: String(media._id), path: body.path });
return { ok: true, jobId };
const created = await createJobForPath(body.path, body.kind);
if (created.enqueued) {
await fileEventsQueue.add('debug', { jobId: created.jobId, mediaFileId: created.mediaFileId, path: body.path });
}
return { ok: true, jobId: created.jobId, deduped: !created.enqueued };
});
}

View File

@@ -11,19 +11,51 @@ export async function jobRoutes(app: FastifyInstance): Promise<void> {
page: z.coerce.number().default(1),
limit: z.coerce.number().default(20),
status: z.string().optional(),
search: z.string().optional()
search: z.string().optional(),
dedupe: z.coerce.boolean().default(true)
})
.parse(req.query);
const filter: any = {};
if (q.status) filter.status = q.status;
if (q.search) filter.$or = [{ 'requestSnapshot.title': { $regex: q.search, $options: 'i' } }];
const skip = (q.page - 1) * q.limit;
const [items, total] = await Promise.all([
JobModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(q.limit).lean(),
JobModel.countDocuments(filter)
]);
let items: any[] = [];
let total = 0;
if (!q.dedupe) {
const filter: any = {};
if (q.status) filter.status = q.status;
if (q.search) filter.$or = [{ 'requestSnapshot.title': { $regex: q.search, $options: 'i' } }];
[items, total] = await Promise.all([
JobModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(q.limit).lean(),
JobModel.countDocuments(filter)
]);
} else {
const pipeline: any[] = [
{ $sort: { createdAt: -1 } },
{ $group: { _id: '$mediaFileId', latest: { $first: '$$ROOT' } } },
{ $replaceRoot: { newRoot: '$latest' } }
];
const postMatch: any = {};
if (q.status) postMatch.status = q.status;
if (q.search) {
postMatch.$or = [
{ 'requestSnapshot.title': { $regex: q.search, $options: 'i' } },
{ 'requestSnapshot.release': { $regex: q.search, $options: 'i' } }
];
}
if (Object.keys(postMatch).length > 0) {
pipeline.push({ $match: postMatch });
}
const [countAgg, listAgg] = await Promise.all([
JobModel.aggregate([...pipeline, { $count: 'total' }]),
JobModel.aggregate([...pipeline, { $sort: { createdAt: -1 } }, { $skip: skip }, { $limit: q.limit }])
]);
total = countAgg[0]?.total ?? 0;
items = listAgg;
}
return { items, total, page: q.page, limit: q.limit };
});

View File

@@ -33,23 +33,67 @@ export async function waitForStable(filePath: string, checks: number, intervalSe
}
export function normalizeSubtitleBuffer(input: Buffer): string {
const normalizeNewlines = (text: string): string => text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const textQualityScore = (text: string): number => {
const replacements = (text.match(/\uFFFD/g) || []).length;
const turkishLetters = (text.match(/[çğıöşüÇĞİÖŞÜ]/g) || []).length;
const suspiciousMojibake = (text.match(/[þýðÞÝÐÃÂ]/g) || []).length;
return turkishLetters * 2 - replacements * 5 - suspiciousMojibake * 2;
};
const maybeRepairTurkishMojibake = (text: string): string => {
const suspicious = (text.match(/[þýðÞÝÐ]/g) || []).length;
if (suspicious < 2) return text;
const repaired = iconv.decode(Buffer.from(text, 'latin1'), 'windows-1254');
return textQualityScore(repaired) > textQualityScore(text) ? repaired : text;
};
const finalizeText = (text: string): string => normalizeNewlines(maybeRepairTurkishMojibake(text));
if (input.length >= 2) {
// UTF-16 LE BOM
if (input[0] === 0xff && input[1] === 0xfe) {
return finalizeText(iconv.decode(input, 'utf16-le'));
}
// UTF-16 BE BOM
if (input[0] === 0xfe && input[1] === 0xff) {
return finalizeText(iconv.decode(input, 'utf16-be'));
}
}
if (input.length >= 3 && input[0] === 0xef && input[1] === 0xbb && input[2] === 0xbf) {
return input.toString('utf8').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return finalizeText(input.toString('utf8'));
}
// If bytes round-trip as UTF-8, prefer UTF-8 regardless of heuristic detection.
const utf8Decoded = input.toString('utf8');
if (Buffer.from(utf8Decoded, 'utf8').equals(input)) {
return finalizeText(utf8Decoded);
}
const detected = jschardet.detect(input);
const enc = (detected.encoding || '').toLowerCase();
const confidence = typeof detected.confidence === 'number' ? detected.confidence : 0;
const pickSingleByteDecode = (): string => {
const tr = iconv.decode(input, 'windows-1254');
const latin = iconv.decode(input, 'latin1');
return textQualityScore(tr) >= textQualityScore(latin) ? tr : latin;
};
let decoded: string;
if (enc.includes('utf')) {
if (enc.includes('utf') && confidence >= 0.6) {
decoded = input.toString('utf8');
} else if (enc.includes('windows-1254') || enc.includes('iso-8859-9')) {
decoded = iconv.decode(input, 'windows-1254');
} else if (enc.includes('windows') || enc.includes('latin') || enc.includes('iso-8859-1') || confidence < 0.7) {
decoded = pickSingleByteDecode();
} else {
decoded = iconv.decode(input, 'latin1');
}
return decoded.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
return finalizeText(decoded);
}
export async function nextSubtitlePath(basePathWithoutExt: string, lang: string, ext: 'srt' | 'ass', overwrite: boolean): Promise<string> {

View File

@@ -0,0 +1,20 @@
export function createEventDeduper(windowMs: number): (filePath: string, now?: number) => boolean {
const recentByPath = new Map<string, number>();
const safeWindow = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : 0;
return (filePath: string, now = Date.now()) => {
const last = recentByPath.get(filePath);
if (typeof last === 'number' && now - last < safeWindow) {
return false;
}
recentByPath.set(filePath, now);
// Keep memory bounded by removing old keys opportunistically.
for (const [p, ts] of recentByPath) {
if (now - ts >= safeWindow) recentByPath.delete(p);
}
return true;
};
}

View File

@@ -3,6 +3,10 @@ import { WatchedPathModel } from '../models/WatchedPath.js';
import { createJobForPath } from '../workers/pipeline.js';
import { fileEventsQueue } from '../queues/queues.js';
import { isVideoFile } from '../utils/file.js';
import { env } from '../config/env.js';
import { createEventDeduper } from './dedup.js';
let watcherStarted = false;
export async function ensureDefaultWatchedPaths(tvPath: string, moviePath: string): Promise<void> {
await WatchedPathModel.updateOne({ path: tvPath }, { $setOnInsert: { path: tvPath, kind: 'tv', enabled: true } }, { upsert: true });
@@ -10,6 +14,9 @@ export async function ensureDefaultWatchedPaths(tvPath: string, moviePath: strin
}
export async function startWatcher(): Promise<void> {
if (watcherStarted) return;
watcherStarted = true;
const watched = await WatchedPathModel.find({ enabled: true }).lean();
const paths = watched.map((w) => w.path);
if (paths.length === 0) {
@@ -18,26 +25,25 @@ export async function startWatcher(): Promise<void> {
}
const byPath = new Map(watched.map((w) => [w.path, w.kind]));
const shouldProcessEvent = createEventDeduper(env.watcherDedupWindowMs);
const watcher = chokidar.watch(paths, { ignoreInitial: false, awaitWriteFinish: false, persistent: true });
watcher.on('add', async (p) => {
if (!isVideoFile(p)) return;
if (!shouldProcessEvent(p)) return;
const kind = resolveKind(p, byPath);
const jobId = await createJobForPath(p, kind === 'movie' ? 'movie' : 'tv');
const media = await import('../models/MediaFile.js');
const mediaDoc = await media.MediaFileModel.findOne({ path: p }).lean();
if (!mediaDoc) return;
await fileEventsQueue.add('add', { jobId, mediaFileId: String(mediaDoc._id), path: p });
const created = await createJobForPath(p, kind === 'movie' ? 'movie' : 'tv');
if (!created.enqueued) return;
await fileEventsQueue.add('add', { jobId: created.jobId, mediaFileId: created.mediaFileId, path: p });
});
watcher.on('change', async (p) => {
if (!isVideoFile(p)) return;
if (!shouldProcessEvent(p)) return;
const kind = resolveKind(p, byPath);
const jobId = await createJobForPath(p, kind === 'movie' ? 'movie' : 'tv');
const media = await import('../models/MediaFile.js');
const mediaDoc = await media.MediaFileModel.findOne({ path: p }).lean();
if (!mediaDoc) return;
await fileEventsQueue.add('change', { jobId, mediaFileId: String(mediaDoc._id), path: p });
const created = await createJobForPath(p, kind === 'movie' ? 'movie' : 'tv');
if (!created.enqueued) return;
await fileEventsQueue.add('change', { jobId: created.jobId, mediaFileId: created.mediaFileId, path: p });
});
watcher.on('unlink', async (p) => {

View File

@@ -1,5 +1,6 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { Job } from 'bullmq';
import { Types } from 'mongoose';
import { JobModel } from '../models/Job.js';
import { MediaFileModel } from '../models/MediaFile.js';
@@ -32,9 +33,23 @@ interface FinalizeData {
}
const activeWorkers: any[] = [];
let workersStarted = false;
const ACTIVE_JOB_STATUSES = [
'PENDING',
'WAITING_FILE_STABLE',
'PARSED',
'ANALYZED',
'REQUESTING_API',
'FOUND_TEMP',
'NORMALIZING_ENCODING',
'WRITING_SUBTITLE'
] as const;
export function startWorkers(): void {
activeWorkers.push(createWorker('fileEvents', async (job) => {
if (workersStarted) return;
workersStarted = true;
activeWorkers.push(createWorker('fileEvents', async (job: Job<FileEventData>) => {
const data = job.data as FileEventData;
const settings = await SettingModel.findById('global').lean();
if (!settings) throw new Error('settings missing');
@@ -47,7 +62,31 @@ export function startWorkers(): void {
return;
}
const stable = await waitForStable(data.path, settings.stableChecks, settings.stableIntervalSeconds);
let stable = false;
try {
stable = await waitForStable(data.path, settings.stableChecks, settings.stableIntervalSeconds);
} catch (err: any) {
const missing = err?.code === 'ENOENT';
if (missing) {
await MediaFileModel.findByIdAndUpdate(data.mediaFileId, { status: 'MISSING', lastSeenAt: new Date() });
}
await JobModel.findByIdAndUpdate(data.jobId, {
status: 'ERROR',
error: {
code: missing ? 'FILE_NOT_FOUND' : 'FILE_STABLE_CHECK_FAILED',
message: missing ? 'file not found during stability check' : (err?.message || 'stability check failed')
}
});
await writeJobLog({
jobId: data.jobId,
step: 'JOB_ERROR',
level: 'error',
message: missing ? `File not found during stability check: ${data.path}` : 'Stability check failed',
meta: missing ? undefined : { error: err?.message }
});
return;
}
if (!stable) {
await JobModel.findByIdAndUpdate(data.jobId, { status: 'ERROR', error: { code: 'FILE_NOT_STABLE', message: 'file not stable' } });
await writeJobLog({ jobId: data.jobId, step: 'JOB_ERROR', level: 'error', message: 'File did not become stable' });
@@ -87,7 +126,7 @@ export function startWorkers(): void {
await mediaAnalysisQueue.add('analyze', { jobId: data.jobId, mediaFileId: data.mediaFileId });
}));
activeWorkers.push(createWorker('mediaAnalysis', async (job) => {
activeWorkers.push(createWorker('mediaAnalysis', async (job: Job<SubtitleFetchData>) => {
const { jobId, mediaFileId } = job.data as SubtitleFetchData;
const media = await MediaFileModel.findById(mediaFileId).lean();
if (!media) return;
@@ -114,7 +153,7 @@ export function startWorkers(): void {
await subtitleFetchQueue.add('search', { jobId, mediaFileId });
}));
activeWorkers.push(createWorker('subtitleFetch', async (job) => {
activeWorkers.push(createWorker('subtitleFetch', async (job: Job<SubtitleFetchData>) => {
const { jobId, mediaFileId } = job.data as SubtitleFetchData;
const media = await MediaFileModel.findById(mediaFileId).lean();
const settings = await SettingModel.findById('global').lean();
@@ -135,7 +174,8 @@ export function startWorkers(): void {
mediaInfo: media.mediaInfo,
preferHI: settings.preferHI,
preferForced: settings.preferForced,
securityLimits: settings.securityLimits
securityLimits: settings.securityLimits,
features: settings.features
};
const res = await apiClient.post('/v1/subtitles/search', payload);
@@ -189,7 +229,7 @@ export function startWorkers(): void {
});
}));
activeWorkers.push(createWorker('finalizeWrite', async (job) => {
activeWorkers.push(createWorker('finalizeWrite', async (job: Job<FinalizeData>) => {
const data = job.data as FinalizeData;
const media = await MediaFileModel.findById(data.mediaFileId).lean();
const settings = await SettingModel.findById('global').lean();
@@ -205,14 +245,22 @@ export function startWorkers(): void {
const parsed = path.parse(media.path);
const target = await nextSubtitlePath(path.join(parsed.dir, parsed.name), data.lang, extensionFromPath(data.bestPath), settings.overwriteExisting);
await writeJobLog({ jobId: data.jobId, step: 'FINAL_TARGET_PATH_RESOLVED', message: target });
const targetExists = target !== `${path.join(parsed.dir, parsed.name)}.${data.lang}.${extensionFromPath(data.bestPath)}`;
if (targetExists) {
await writeJobLog({ jobId: data.jobId, step: 'WRITE_TARGET_SKIPPED_EXISTS', message: 'Target exists, using incremented filename', meta: { target } });
}
await fs.writeFile(target, normalized, 'utf8');
await writeJobLog({ jobId: data.jobId, step: 'WRITE_TARGET_DONE', message: target });
const tempTarget = `${target}.tmp-${Date.now()}`;
await fs.writeFile(tempTarget, normalized, 'utf8');
await fs.rename(tempTarget, target);
await writeJobLog({
jobId: data.jobId,
step: 'WRITE_TARGET_DONE',
message: target,
meta: { bytes: Buffer.byteLength(normalized, 'utf8') }
});
await JobModel.findByIdAndUpdate(data.jobId, {
status: 'DONE',
@@ -240,7 +288,10 @@ export function startWorkers(): void {
}));
}
export async function createJobForPath(filePath: string, mediaTypeHint: 'tv' | 'movie'): Promise<string> {
export async function createJobForPath(
filePath: string,
mediaTypeHint: 'tv' | 'movie'
): Promise<{ jobId: string; mediaFileId: string; enqueued: boolean }> {
const st = await fs.stat(filePath);
const media = await MediaFileModel.findOneAndUpdate(
@@ -258,6 +309,27 @@ export async function createJobForPath(filePath: string, mediaTypeHint: 'tv' | '
{ upsert: true, new: true }
);
const existing = await JobModel.findOne({
mediaFileId: media._id,
status: { $in: ACTIVE_JOB_STATUSES }
})
.sort({ createdAt: -1 })
.lean();
if (existing) {
await writeJobLog({
jobId: existing._id as Types.ObjectId,
step: 'WATCH_EVENT_DEDUPED',
message: `Skipped duplicate watch event for ${filePath}`,
meta: { mediaFileId: String(media._id), existingStatus: existing.status }
});
return {
jobId: String(existing._id),
mediaFileId: String(media._id),
enqueued: false
};
}
const created = await JobModel.create({
mediaFileId: media._id,
status: 'PENDING',
@@ -270,5 +342,9 @@ export async function createJobForPath(filePath: string, mediaTypeHint: 'tv' | '
message: `Queued watch event for ${filePath}`
});
return String(created._id);
return {
jobId: String(created._id),
mediaFileId: String(media._id),
enqueued: true
};
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import iconv from 'iconv-lite';
import { normalizeSubtitleBuffer } from '../src/utils/file.js';
describe('normalizeSubtitleBuffer', () => {
const sample = '1\r\n00:00:01,000 --> 00:00:02,000\r\nÇığ ÖşÜ ıİ\r\n';
it('keeps valid utf8 Turkish characters intact', () => {
const buf = Buffer.from(sample, 'utf8');
const out = normalizeSubtitleBuffer(buf);
expect(out).toBe('1\n00:00:01,000 --> 00:00:02,000\nÇığ ÖşÜ ıİ\n');
});
it('decodes windows-1254 Turkish text correctly', () => {
const buf = iconv.encode(sample, 'windows-1254');
const out = normalizeSubtitleBuffer(buf);
expect(out).toBe('1\n00:00:01,000 --> 00:00:02,000\nÇığ ÖşÜ ıİ\n');
});
it('decodes utf16-le BOM correctly', () => {
const buf = iconv.encode(sample, 'utf16-le');
const withBom = Buffer.concat([Buffer.from([0xff, 0xfe]), buf]);
const out = normalizeSubtitleBuffer(withBom);
expect(out).toBe('1\n00:00:01,000 --> 00:00:02,000\nÇığ ÖşÜ ıİ\n');
});
it('repairs mojibake form with \u00fd/\u00fe/\u00f0 Turkish corruption', () => {
const mojibake = '1\r\n00:00:01,000 --> 00:00:02,000\r\nKýz þöyle dedi: aðýr bir iþ.\r\n';
const buf = Buffer.from(mojibake, 'utf8');
const out = normalizeSubtitleBuffer(buf);
expect(out).toContain('Kız şöyle dedi: ağır bir iş.');
});
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { createEventDeduper } from '../src/watcher/dedup.js';
describe('createEventDeduper', () => {
it('suppresses repeated events inside window for same path', () => {
const dedupe = createEventDeduper(1000);
expect(dedupe('/media/movie/a.mkv', 1000)).toBe(true);
expect(dedupe('/media/movie/a.mkv', 1500)).toBe(false);
expect(dedupe('/media/movie/a.mkv', 2001)).toBe(true);
});
it('does not suppress different paths', () => {
const dedupe = createEventDeduper(1000);
expect(dedupe('/media/movie/a.mkv', 1000)).toBe(true);
expect(dedupe('/media/movie/b.mkv', 1200)).toBe(true);
});
});