Added library mixin to YTMusic and Jellyfin
This commit is contained in:
@@ -8,3 +8,5 @@ A self hosted client to stream music from all your favorite music streaming serv
|
|||||||
- Search for content across all music platforms
|
- Search for content across all music platforms
|
||||||
- Synchronize your playlist across every service
|
- Synchronize your playlist across every service
|
||||||
- Local downloads for offline playback
|
- Local downloads for offline playback
|
||||||
|
|
||||||
|

|
||||||
|
|||||||
620
package-lock.json
generated
620
package-lock.json
generated
@@ -16,10 +16,7 @@
|
|||||||
"fast-average-color": "^9.4.0",
|
"fast-average-color": "^9.4.0",
|
||||||
"googleapis": "^133.0.0",
|
"googleapis": "^133.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"musicbrainz-api": "^0.15.0",
|
"musicbrainz-api": "^0.15.0"
|
||||||
"node-vibrant": "^3.2.1-alpha.1",
|
|
||||||
"pocketbase": "^0.21.1",
|
|
||||||
"type-fest": "^4.12.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
@@ -63,17 +60,6 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
|
||||||
"version": "7.24.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz",
|
|
||||||
"integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==",
|
|
||||||
"dependencies": {
|
|
||||||
"regenerator-runtime": "^0.14.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.20.2",
|
"version": "0.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
|
||||||
@@ -468,141 +454,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jimp/bmp": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-9edAxu7N2FX7vzkdl5Jo1BbACfycUtBQX+XBMcHA2bk62P8R0otgkHg798frgAk/WxQIzwxqOH6wMiCwrlAzdQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"@jimp/utils": "^0.16.13",
|
|
||||||
"bmp-js": "^0.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@jimp/custom": ">=0.3.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/core": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-qXpA1tzTnlkTku9yqtuRtS/wVntvE6f3m3GNxdTdtmc+O+Wcg9Xo2ABPMh7Nc0AHbMKzwvwgB2JnjZmlmJEObg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"@jimp/utils": "^0.16.13",
|
|
||||||
"any-base": "^1.1.0",
|
|
||||||
"buffer": "^5.2.0",
|
|
||||||
"exif-parser": "^0.1.12",
|
|
||||||
"file-type": "^16.5.4",
|
|
||||||
"load-bmfont": "^1.3.1",
|
|
||||||
"mkdirp": "^0.5.1",
|
|
||||||
"phin": "^2.9.1",
|
|
||||||
"pixelmatch": "^4.0.2",
|
|
||||||
"tinycolor2": "^1.4.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/custom": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"@jimp/core": "^0.16.13"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/gif": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-yFAMZGv3o+YcjXilMWWwS/bv1iSqykFahFMSO169uVMtfQVfa90kt4/kDwrXNR6Q9i6VHpFiGZMlF2UnHClBvg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"@jimp/utils": "^0.16.13",
|
|
||||||
"gifwrap": "^0.9.2",
|
|
||||||
"omggif": "^1.0.9"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@jimp/custom": ">=0.3.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/jpeg": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-BJHlDxzTlCqP2ThqP8J0eDrbBfod7npWCbJAcfkKqdQuFk0zBPaZ6KKaQKyKxmWJ87Z6ohANZoMKEbtvrwz1AA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"@jimp/utils": "^0.16.13",
|
|
||||||
"jpeg-js": "^0.4.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@jimp/custom": ">=0.3.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/plugin-resize": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"@jimp/utils": "^0.16.13"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@jimp/custom": ">=0.3.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/png": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-8cGqINvbWJf1G0Her9zbq9I80roEX0A+U45xFby3tDWfzn+Zz8XKDF1Nv9VUwVx0N3zpcG1RPs9hfheG4Cq2kg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"@jimp/utils": "^0.16.13",
|
|
||||||
"pngjs": "^3.3.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@jimp/custom": ">=0.3.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/tiff": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-oJY8d9u95SwW00VPHuCNxPap6Q1+E/xM5QThb9Hu+P6EGuu6lIeLaNBMmFZyblwFbwrH+WBOZlvIzDhi4Dm/6Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"utif": "^2.0.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@jimp/custom": ">=0.3.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/types": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-mC0yVNUobFDjoYLg4hoUwzMKgNlxynzwt3cDXzumGvRJ7Kb8qQGOWJQjQFo5OxmGExqzPphkirdbBF88RVLBCg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"@jimp/bmp": "^0.16.13",
|
|
||||||
"@jimp/gif": "^0.16.13",
|
|
||||||
"@jimp/jpeg": "^0.16.13",
|
|
||||||
"@jimp/png": "^0.16.13",
|
|
||||||
"@jimp/tiff": "^0.16.13",
|
|
||||||
"timm": "^1.6.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@jimp/custom": ">=0.3.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/utils": {
|
|
||||||
"version": "0.16.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.16.13.tgz",
|
|
||||||
"integrity": "sha512-VyCpkZzFTHXtKgVO35iKN0sYR10psGpV6SkcSeV4oF7eSYlR8Bl6aQLCzVeFjvESF7mxTmIiI3/XrMobVrtxDA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.7.2",
|
|
||||||
"regenerator-runtime": "^0.13.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jimp/utils/node_modules/regenerator-runtime": {
|
|
||||||
"version": "0.13.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
|
||||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.3",
|
"version": "0.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
|
||||||
@@ -1002,11 +853,6 @@
|
|||||||
"node": ">=14.16"
|
"node": ">=14.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tokenizer/token": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/better-sqlite3": {
|
"node_modules/@types/better-sqlite3": {
|
||||||
"version": "7.6.8",
|
"version": "7.6.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.8.tgz",
|
||||||
@@ -1088,103 +934,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||||
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="
|
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="
|
||||||
},
|
},
|
||||||
"node_modules/@vibrant/color": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/color/-/color-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-cvm+jAPwao2NerTr3d1JttYyLhp3eD/AQBeevxF7KT6HctToWZCwr2AeTr003/wKgbjzdOV1qySnbyOeu+R+Jw=="
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/core": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/core/-/core-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-X9Oa9WfPEQnZ6L+5dLRlh+IlsxJkYTw9b/g3stFKoNXbVRKCeXHmH48l7jIBBOg3VcXOGUdsYBqsTwPNkIveaA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@vibrant/color": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/generator": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/image": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/quantizer": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/types": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/worker": "^3.2.1-alpha.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/generator": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/generator/-/generator-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-luS5YvMhwMqG01YTj1dJ+cmkuIw1VCByOR6zIaCOwQqI/mcOs88JBWcA1r2TywJTOPlVpjfnDvAlyaKBKh4dMA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@vibrant/color": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/types": "^3.2.1-alpha.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/generator-default": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/generator-default/-/generator-default-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-BWnQhDaz92UhyHnpdAzKXHQecY+jvyMXtzjKYbveFxThm6+HVoLjwONlbck7oyOpFzV2OM7V11XuR85BxaHvjw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@vibrant/color": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/generator": "^3.2.1-alpha.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/image": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/image/-/image-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-4aF5k79QfyhZOqRovJpbnIjWfe3uuWhY8voqVdd4/qgu4o70/AwVlM+pYmCaJVzI45VWNWWHYA5QlYuKsXnBqQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@vibrant/color": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/types": "^3.2.1-alpha.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/image-browser": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/image-browser/-/image-browser-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-6xWvQfB20sE6YtCWylgEAHuee3iD8h3aFIDbCS2yj7jIelKcYTrrp5jg2d2BhOOB6pC5JzF+QfpCrm0DmAIlgQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@vibrant/image": "^3.2.1-alpha.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/image-node": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/image-node/-/image-node-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-/Io/Rpo4EkO6AhaXdcxUXkbOFhSFtjm0LSAM4c0AyGA5EbC8PyZqjk8b11bQAEMCaYaweFQfTdGD7oVbXe21CQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@jimp/custom": "^0.16.1",
|
|
||||||
"@jimp/plugin-resize": "^0.16.1",
|
|
||||||
"@jimp/types": "^0.16.1",
|
|
||||||
"@vibrant/image": "^3.2.1-alpha.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/quantizer": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/quantizer/-/quantizer-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-iHnPx/+n4iLtYLm1GClSfyg2fFbMatFG0ipCyp9M6tXNIPAg+pSvUJSGBnVnH7Nl/bR8Gkkj1h0pJ4RsKcdIrQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@vibrant/color": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/image": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/types": "^3.2.1-alpha.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/quantizer-mmcq": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/quantizer-mmcq/-/quantizer-mmcq-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-Wuk9PTZtxr8qsWTcgP6lcrrmrq36syVwxf+BUxdgQYntBcQ053SaN34lVGOJ0WPdK5vABoxbYljhceCgiILtZw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@vibrant/color": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/image": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/quantizer": "^3.2.1-alpha.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/types": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/types/-/types-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-ts9u7nsrENoYI5s0MmPOeY5kCLFKvQndKVDOPFCbTA0z493uhDp8mpiQhjFYTf3kPbS04z9zbHLE2luFC7x4KQ=="
|
|
||||||
},
|
|
||||||
"node_modules/@vibrant/worker": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vibrant/worker/-/worker-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-mtSlBdHkFNr4FOnMtqtHJxy9z5AsUcZzGlpiHzvWOoaoN9lNTDPwxOBd0q4VTYWuGPrIm6Fuq5m7aRbLv7KqiQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@vibrant/types": "^3.2.1-alpha.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.11.3",
|
"version": "8.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
||||||
@@ -1232,11 +981,6 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/any-base": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="
|
|
||||||
},
|
|
||||||
"node_modules/any-promise": {
|
"node_modules/any-promise": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
@@ -1400,11 +1144,6 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bmp-js": {
|
|
||||||
"version": "0.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
|
||||||
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw=="
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
@@ -1491,14 +1230,6 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer-equal": {
|
|
||||||
"version": "0.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz",
|
|
||||||
"integrity": "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
@@ -1877,11 +1608,6 @@
|
|||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/dom-walk": {
|
|
||||||
"version": "0.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
|
||||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
|
||||||
},
|
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
@@ -2003,11 +1729,6 @@
|
|||||||
"@types/estree": "^1.0.0"
|
"@types/estree": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/exif-parser": {
|
|
||||||
"version": "0.1.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
|
|
||||||
"integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="
|
|
||||||
},
|
|
||||||
"node_modules/expand-template": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
@@ -2054,22 +1775,6 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/file-type": {
|
|
||||||
"version": "16.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
|
|
||||||
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
|
|
||||||
"dependencies": {
|
|
||||||
"readable-web-to-node-stream": "^3.0.0",
|
|
||||||
"strtok3": "^6.2.4",
|
|
||||||
"token-types": "^4.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/file-uri-to-path": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
@@ -2225,15 +1930,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/gifwrap": {
|
|
||||||
"version": "0.9.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.9.4.tgz",
|
|
||||||
"integrity": "sha512-MDMwbhASQuVeD4JKd1fKgNgCRL3fGqMM4WaqpNhWO0JiMOAjbQdumbs4BbBZEy9/M00EHEjKN3HieVhCUlwjeQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"image-q": "^4.0.0",
|
|
||||||
"omggif": "^1.0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/github-from-package": {
|
"node_modules/github-from-package": {
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||||
@@ -2271,15 +1967,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/global": {
|
|
||||||
"version": "4.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
|
||||||
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
|
||||||
"dependencies": {
|
|
||||||
"min-document": "^2.19.0",
|
|
||||||
"process": "^0.11.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/globalyzer": {
|
"node_modules/globalyzer": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
|
||||||
@@ -2524,19 +2211,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/image-q": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "16.9.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/image-q/node_modules/@types/node": {
|
|
||||||
"version": "16.9.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz",
|
|
||||||
"integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="
|
|
||||||
},
|
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||||
@@ -2625,11 +2299,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-function": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="
|
|
||||||
},
|
|
||||||
"node_modules/is-glob": {
|
"node_modules/is-glob": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
@@ -2704,11 +2373,6 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jpeg-js": {
|
|
||||||
"version": "0.4.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
|
||||||
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="
|
|
||||||
},
|
|
||||||
"node_modules/json-bigint": {
|
"node_modules/json-bigint": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||||
@@ -2807,21 +2471,6 @@
|
|||||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/load-bmfont": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-8UyQoYmdRDy81Brz6aLAUhfZLwr5zV0L3taTQ4hju7m6biuwiWiJXjPhBJxbUQJA8PrkvJ/7Enqmwk2sM14soA==",
|
|
||||||
"dependencies": {
|
|
||||||
"buffer-equal": "0.0.1",
|
|
||||||
"mime": "^1.3.4",
|
|
||||||
"parse-bmfont-ascii": "^1.0.3",
|
|
||||||
"parse-bmfont-binary": "^1.0.5",
|
|
||||||
"parse-bmfont-xml": "^1.1.4",
|
|
||||||
"phin": "^2.9.1",
|
|
||||||
"xhr": "^2.0.1",
|
|
||||||
"xtend": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/locate-character": {
|
"node_modules/locate-character": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
@@ -2923,17 +2572,6 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
|
||||||
"bin": {
|
|
||||||
"mime": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
@@ -2964,14 +2602,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/min-document": {
|
|
||||||
"version": "2.19.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
|
|
||||||
"integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"dom-walk": "^0.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/min-indent": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -3014,6 +2644,7 @@
|
|||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@@ -3142,25 +2773,6 @@
|
|||||||
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
|
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/node-vibrant": {
|
|
||||||
"version": "3.2.1-alpha.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-vibrant/-/node-vibrant-3.2.1-alpha.1.tgz",
|
|
||||||
"integrity": "sha512-EQergCp7fvbvUCE0VMCBnvaAV0lGWSP8SXLmuWQIBzQK5M5pIwcd9fIOXuzFkJx/8hUiiiLvAzzGDS/bIy2ikA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "^10.12.18",
|
|
||||||
"@vibrant/core": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/generator-default": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/image-browser": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/image-node": "^3.2.1-alpha.1",
|
|
||||||
"@vibrant/quantizer-mmcq": "^3.2.1-alpha.1",
|
|
||||||
"url": "^0.11.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-vibrant/node_modules/@types/node": {
|
|
||||||
"version": "10.17.60",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
|
||||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="
|
|
||||||
},
|
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -3216,11 +2828,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/omggif": {
|
|
||||||
"version": "1.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
|
|
||||||
"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="
|
|
||||||
},
|
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
@@ -3237,11 +2844,6 @@
|
|||||||
"node": ">=14.16"
|
"node": ">=14.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pako": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
|
||||||
},
|
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -3254,30 +2856,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/parse-bmfont-ascii": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="
|
|
||||||
},
|
|
||||||
"node_modules/parse-bmfont-binary": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="
|
|
||||||
},
|
|
||||||
"node_modules/parse-bmfont-xml": {
|
|
||||||
"version": "1.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz",
|
|
||||||
"integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==",
|
|
||||||
"dependencies": {
|
|
||||||
"xml-parse-from-string": "^1.0.0",
|
|
||||||
"xml2js": "^0.5.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/parse-headers": {
|
|
||||||
"version": "2.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.5.tgz",
|
|
||||||
"integrity": "sha512-ft3iAoLOB/MlwbNXgzy43SWGP6sQki2jQvAyBg/zDFAgr9bfNWZIUj42Kw2eJIl8kEi4PbgE6U1Zau/HwI75HA=="
|
|
||||||
},
|
|
||||||
"node_modules/path-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
@@ -3318,18 +2896,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/peek-readable": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/Borewit"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/periscopic": {
|
"node_modules/periscopic": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
|
||||||
@@ -3341,12 +2907,6 @@
|
|||||||
"is-reference": "^3.0.0"
|
"is-reference": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/phin": {
|
|
||||||
"version": "2.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/phin/-/phin-2.9.3.tgz",
|
|
||||||
"integrity": "sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==",
|
|
||||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info."
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
|
||||||
@@ -3383,30 +2943,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pixelmatch": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==",
|
|
||||||
"dependencies": {
|
|
||||||
"pngjs": "^3.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"pixelmatch": "bin/pixelmatch"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pngjs": {
|
|
||||||
"version": "3.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",
|
|
||||||
"integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pocketbase": {
|
|
||||||
"version": "0.21.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.21.1.tgz",
|
|
||||||
"integrity": "sha512-0PvCP4pKtxsV9kwldEGyibEvhwOcx9jSCrz3WN5CgPILJfM0z76f1op9WE8/8UgikDsMdRsc5iBLfKintrJS1g=="
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.38",
|
"version": "8.4.38",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
|
||||||
@@ -3672,14 +3208,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/process": {
|
|
||||||
"version": "0.11.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
|
||||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/psl": {
|
"node_modules/psl": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
|
||||||
@@ -3694,11 +3222,6 @@
|
|||||||
"once": "^1.3.1"
|
"once": "^1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/punycode": {
|
|
||||||
"version": "1.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
|
||||||
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="
|
|
||||||
},
|
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.11.2",
|
"version": "6.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz",
|
||||||
@@ -3793,21 +3316,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readable-web-to-node-stream": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
|
|
||||||
"dependencies": {
|
|
||||||
"readable-stream": "^3.6.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/Borewit"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -3820,11 +3328,6 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/regenerator-runtime": {
|
|
||||||
"version": "0.14.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
|
||||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
|
|
||||||
},
|
|
||||||
"node_modules/requires-port": {
|
"node_modules/requires-port": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
@@ -3997,11 +3500,6 @@
|
|||||||
"rimraf": "^2.5.2"
|
"rimraf": "^2.5.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sax": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA=="
|
|
||||||
},
|
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.5.4",
|
"version": "7.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||||
@@ -4321,22 +3819,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strtok3": {
|
|
||||||
"version": "6.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
|
||||||
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@tokenizer/token": "^0.3.0",
|
|
||||||
"peek-readable": "^4.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/Borewit"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sucrase": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.0",
|
"version": "3.35.0",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||||
@@ -4635,11 +4117,6 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/timm": {
|
|
||||||
"version": "1.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz",
|
|
||||||
"integrity": "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw=="
|
|
||||||
},
|
|
||||||
"node_modules/tiny-glob": {
|
"node_modules/tiny-glob": {
|
||||||
"version": "0.2.9",
|
"version": "0.2.9",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
||||||
@@ -4650,11 +4127,6 @@
|
|||||||
"globrex": "^0.1.2"
|
"globrex": "^0.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinycolor2": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -4667,22 +4139,6 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/token-types": {
|
|
||||||
"version": "4.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
|
|
||||||
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@tokenizer/token": "^0.3.0",
|
|
||||||
"ieee754": "^1.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/Borewit"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/totalist": {
|
"node_modules/totalist": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
@@ -4742,17 +4198,6 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/type-fest": {
|
|
||||||
"version": "4.12.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.12.0.tgz",
|
|
||||||
"integrity": "sha512-5Y2/pp2wtJk8o08G0CMkuFPCO354FGwk/vbidxrdhRGZfd0tFnb4Qb8anp9XxXriwBgVPjdWbKpGl4J9lJY2jQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
|
||||||
@@ -4809,15 +4254,6 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/url": {
|
|
||||||
"version": "0.11.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/url/-/url-0.11.3.tgz",
|
|
||||||
"integrity": "sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==",
|
|
||||||
"dependencies": {
|
|
||||||
"punycode": "^1.4.1",
|
|
||||||
"qs": "^6.11.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/url-parse": {
|
"node_modules/url-parse": {
|
||||||
"version": "1.5.10",
|
"version": "1.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||||
@@ -4832,14 +4268,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
||||||
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="
|
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="
|
||||||
},
|
},
|
||||||
"node_modules/utif": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/utif/-/utif-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==",
|
|
||||||
"dependencies": {
|
|
||||||
"pako": "^1.0.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -5051,50 +4479,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||||
},
|
},
|
||||||
"node_modules/xhr": {
|
|
||||||
"version": "2.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/xhr/-/xhr-2.6.0.tgz",
|
|
||||||
"integrity": "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==",
|
|
||||||
"dependencies": {
|
|
||||||
"global": "~4.4.0",
|
|
||||||
"is-function": "^1.0.1",
|
|
||||||
"parse-headers": "^2.0.0",
|
|
||||||
"xtend": "^4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xml-parse-from-string": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="
|
|
||||||
},
|
|
||||||
"node_modules/xml2js": {
|
|
||||||
"version": "0.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
|
||||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
|
||||||
"dependencies": {
|
|
||||||
"sax": ">=0.6.0",
|
|
||||||
"xmlbuilder": "~11.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xmlbuilder": {
|
|
||||||
"version": "11.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
|
||||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/xtend": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
|||||||
@@ -35,9 +35,6 @@
|
|||||||
"fast-average-color": "^9.4.0",
|
"fast-average-color": "^9.4.0",
|
||||||
"googleapis": "^133.0.0",
|
"googleapis": "^133.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"musicbrainz-api": "^0.15.0",
|
"musicbrainz-api": "^0.15.0"
|
||||||
"node-vibrant": "^3.2.1-alpha.1",
|
|
||||||
"pocketbase": "^0.21.1",
|
|
||||||
"type-fest": "^4.12.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/app.d.ts
vendored
27
src/app.d.ts
vendored
@@ -40,6 +40,13 @@ declare global {
|
|||||||
profilePicture?: string
|
profilePicture?: string
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type MediaItemTypeMap = {
|
||||||
|
song: Song
|
||||||
|
album: Album
|
||||||
|
artist: Artist
|
||||||
|
playlist: Playlist
|
||||||
|
}
|
||||||
|
|
||||||
type SearchFilterMap<Filter> =
|
type SearchFilterMap<Filter> =
|
||||||
Filter extends 'song' ? Song :
|
Filter extends 'song' ? Song :
|
||||||
Filter extends 'album' ? Album :
|
Filter extends 'album' ? Album :
|
||||||
@@ -49,9 +56,19 @@ declare global {
|
|||||||
never
|
never
|
||||||
|
|
||||||
interface Connection {
|
interface Connection {
|
||||||
public id: string
|
public readonly id: string
|
||||||
|
|
||||||
|
/** Retireves general information about the connection */
|
||||||
getConnectionInfo: () => Promise<ConnectionInfo>
|
getConnectionInfo: () => Promise<ConnectionInfo>
|
||||||
|
|
||||||
|
/** Get's the user's recommendations from the corresponding service */
|
||||||
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
|
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param searchTerm The string of text to query
|
||||||
|
* @param filter Optional. A string of either 'song', 'album', 'artist', or 'playlist' to filter the kind of media items queried
|
||||||
|
* @returns A promise of an array of media items
|
||||||
|
*/
|
||||||
search: <T extends 'song' | 'album' | 'artist' | 'playlist'>(searchTerm: string, filter?: T) => Promise<SearchFilterMap<T>[]>
|
search: <T extends 'song' | 'album' | 'artist' | 'playlist'>(searchTerm: string, filter?: T) => Promise<SearchFilterMap<T>[]>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,7 +104,13 @@ declare global {
|
|||||||
* @param limit The maximum number of playlist items to return
|
* @param limit The maximum number of playlist items to return
|
||||||
* @returns A promise of the songs in the playlist as and array of Song objects
|
* @returns A promise of the songs in the playlist as and array of Song objects
|
||||||
*/
|
*/
|
||||||
getPlaylistItems: (id: string, startIndex?: number, limit?: number) => Promise<Song[]>
|
getPlaylistItems: (id: string, options?: { startIndex?: number, limit?: number }) => Promise<Song[]>
|
||||||
|
|
||||||
|
public readonly library: {
|
||||||
|
albums: () => Promise<Album[]>
|
||||||
|
artists: () => Promise<Artist[]>
|
||||||
|
playlists: () => Promise<Playlist[]>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// These Schemas should only contain general info data that is necessary for data fetching purposes.
|
// These Schemas should only contain general info data that is necessary for data fetching purposes.
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import { redirect, type Handle } from '@sveltejs/kit'
|
import { redirect, type Handle, type HandleFetch, type RequestEvent } from '@sveltejs/kit'
|
||||||
import { SECRET_JWT_KEY, SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
import { SECRET_INTERNAL_API_KEY, SECRET_JWT_KEY } from '$env/static/private'
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
function verifyAuthToken(event: RequestEvent) {
|
||||||
const nonJwtProtectedRoutes = ['/login', '/api']
|
|
||||||
const urlpath = event.url.pathname
|
|
||||||
|
|
||||||
if (urlpath.startsWith('/api')) {
|
|
||||||
const unprotectedAPIRoutes = ['/api/audio', '/api/remoteImage']
|
|
||||||
|
|
||||||
function checkAuthorization(): boolean {
|
|
||||||
const apikey = event.request.headers.get('apikey') || event.url.searchParams.get('apikey')
|
|
||||||
if (apikey === SECRET_INTERNAL_API_KEY) return true
|
|
||||||
|
|
||||||
const authToken = event.cookies.get('lazuli-auth')
|
const authToken = event.cookies.get('lazuli-auth')
|
||||||
if (!authToken) return false
|
if (!authToken) return false
|
||||||
|
|
||||||
@@ -22,14 +12,21 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
const urlpath = event.url.pathname
|
||||||
|
|
||||||
|
if (urlpath.startsWith('/login')) return resolve(event)
|
||||||
|
|
||||||
|
if (urlpath.startsWith('/api')) {
|
||||||
|
if (event.request.headers.get('apikey') === SECRET_INTERNAL_API_KEY || event.url.searchParams.get('apikey') === SECRET_INTERNAL_API_KEY || verifyAuthToken(event)) {
|
||||||
|
return resolve(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!unprotectedAPIRoutes.includes(urlpath) && !checkAuthorization()) {
|
|
||||||
return new Response('Unauthorized', { status: 401 })
|
return new Response('Unauthorized', { status: 401 })
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!nonJwtProtectedRoutes.some((route) => urlpath.startsWith(route))) {
|
|
||||||
const authToken = event.cookies.get('lazuli-auth')
|
const authToken = event.cookies.get('lazuli-auth')
|
||||||
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
|
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
|
||||||
|
|
||||||
@@ -39,8 +36,12 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
} catch {
|
} catch {
|
||||||
throw redirect(303, `/login?redirect=${urlpath}`)
|
throw redirect(303, `/login?redirect=${urlpath}`)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const response = await resolve(event)
|
return resolve(event)
|
||||||
return response
|
}
|
||||||
|
|
||||||
|
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||||
|
const authorized = verifyAuthToken(event)
|
||||||
|
|
||||||
|
return authorized ? fetch(request) : new Response('Unauthorized', { status: 401 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
}).then((response) => response.json() as Promise<{ items: Song[] }>)
|
}).then((response) => response.json() as Promise<{ items: Song[] }>)
|
||||||
|
|
||||||
const items = itemsResponse.items
|
const items = itemsResponse.items
|
||||||
queueRef.setQueue(...items)
|
queueRef.setQueue({ songs: items })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
on:click={() => {
|
on:click={() => {
|
||||||
switch (mediaItem.type) {
|
switch (mediaItem.type) {
|
||||||
case 'song':
|
case 'song':
|
||||||
queueRef.current = mediaItem
|
queueRef.setQueue({ songs: [mediaItem] })
|
||||||
break
|
break
|
||||||
case 'album':
|
case 'album':
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
|
|||||||
@@ -11,30 +11,27 @@
|
|||||||
let expanded = false
|
let expanded = false
|
||||||
|
|
||||||
let paused = true,
|
let paused = true,
|
||||||
shuffle = false,
|
loop = false
|
||||||
repeat = false
|
|
||||||
|
|
||||||
let volume: number,
|
$: shuffled = $queue.isShuffled
|
||||||
muted = false
|
|
||||||
|
|
||||||
const maxVolume = 0.5
|
const maxVolume = 0.5
|
||||||
|
let volume: number
|
||||||
|
|
||||||
let waiting: boolean
|
let waiting: boolean
|
||||||
|
|
||||||
$: muted ? (volume = 0) : (volume = Number(localStorage.getItem('volume')))
|
function formatTime(seconds: number) {
|
||||||
$: if (volume && !muted) localStorage.setItem('volume', volume.toString())
|
seconds = Math.round(seconds)
|
||||||
|
|
||||||
const formatTime = (seconds: number): string => {
|
|
||||||
seconds = Math.floor(seconds)
|
|
||||||
const hours = Math.floor(seconds / 3600)
|
const hours = Math.floor(seconds / 3600)
|
||||||
seconds = seconds - hours * 3600
|
seconds = seconds - hours * 3600
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
seconds = seconds - minutes * 60
|
seconds = seconds - minutes * 60
|
||||||
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
|
const durationString = `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
return hours > 0 ? `${hours}:`.concat(durationString) : durationString
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (currentlyPlaying) updateMediaSession(currentlyPlaying)
|
$: if (currentlyPlaying) updateMediaSession(currentlyPlaying)
|
||||||
const updateMediaSession = (media: Song) => {
|
function updateMediaSession(media: Song) {
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
title: media.name,
|
title: media.name,
|
||||||
@@ -51,7 +48,7 @@
|
|||||||
volume = Number(storedVolume)
|
volume = Number(storedVolume)
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('volume', (maxVolume / 2).toString())
|
localStorage.setItem('volume', (maxVolume / 2).toString())
|
||||||
volume = 0.5
|
volume = maxVolume / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
@@ -103,13 +100,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<section class="flex flex-col justify-center gap-1">
|
<section class="flex flex-col justify-center gap-1">
|
||||||
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
|
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
|
||||||
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') ?? currentlyPlaying.uploader?.name}</div>
|
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<section class="flex min-w-max flex-col items-center justify-center gap-1">
|
<section class="flex min-w-max flex-col items-center justify-center gap-1">
|
||||||
<div class="flex items-center gap-3 text-lg">
|
<div class="flex items-center gap-3 text-lg">
|
||||||
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-8">
|
<button on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())} class="aspect-square h-8">
|
||||||
<i class="fa-solid fa-shuffle" />
|
<i class="fa-solid {shuffled ? 'fa-shuffle' : 'fa-right-left'}" />
|
||||||
</button>
|
</button>
|
||||||
<button class="aspect-square h-8" on:click={() => $queue.previous()}>
|
<button class="aspect-square h-8" on:click={() => $queue.previous()}>
|
||||||
<i class="fa-solid fa-backward-step" />
|
<i class="fa-solid fa-backward-step" />
|
||||||
@@ -124,8 +121,8 @@
|
|||||||
<button class="aspect-square h-8" on:click={() => $queue.next()}>
|
<button class="aspect-square h-8" on:click={() => $queue.next()}>
|
||||||
<i class="fa-solid fa-forward-step" />
|
<i class="fa-solid fa-forward-step" />
|
||||||
</button>
|
</button>
|
||||||
<button on:click={() => (repeat = !repeat)} class="aspect-square h-8">
|
<button on:click={() => (loop = !loop)} class="aspect-square h-8">
|
||||||
<i class="fa-solid fa-repeat" />
|
<i class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-items-center gap-2">
|
<div class="flex items-center justify-items-center gap-2">
|
||||||
@@ -149,11 +146,17 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="flex items-center justify-end gap-2 pr-2 text-lg">
|
<section class="flex items-center justify-end gap-2 pr-2 text-lg">
|
||||||
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
||||||
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
|
<button on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))} class="aspect-square h-8">
|
||||||
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
||||||
</button>
|
</button>
|
||||||
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
||||||
<Slider bind:value={volume} max={maxVolume} />
|
<Slider
|
||||||
|
bind:value={volume}
|
||||||
|
max={maxVolume}
|
||||||
|
on:seeked={() => {
|
||||||
|
if (volume > 0) localStorage.setItem('volume', volume.toString())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="aspect-square h-8" on:click={() => (expanded = true)}>
|
<button class="aspect-square h-8" on:click={() => (expanded = true)}>
|
||||||
@@ -188,7 +191,7 @@
|
|||||||
<div class="h-20 w-20 bg-cover bg-center" style="background-image: url('/api/remoteImage?url={item.thumbnailUrl}');" />
|
<div class="h-20 w-20 bg-cover bg-center" style="background-image: url('/api/remoteImage?url={item.thumbnailUrl}');" />
|
||||||
<div class="justify-items-left text-left">
|
<div class="justify-items-left text-left">
|
||||||
<div class="line-clamp-1">{item.name}</div>
|
<div class="line-clamp-1">{item.name}</div>
|
||||||
<div class="mt-[.15rem] line-clamp-1 text-neutral-400">{item.artists?.map((artist) => artist.name).join(', ') ?? item.uploader?.name}</div>
|
<div class="mt-[.15rem] line-clamp-1 text-neutral-400">{item.artists?.map((artist) => artist.name).join(', ') || item.uploader?.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="mr-4 text-right">{formatTime(item.duration)}</span>
|
<span class="mr-4 text-right">{formatTime(item.duration)}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -223,7 +226,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;">
|
<div class="line-clamp-1 flex flex-nowrap" style="font-size: 0;">
|
||||||
{#if 'artists' in currentlyPlaying && currentlyPlaying.artists && currentlyPlaying.artists.length > 0}
|
{#if currentlyPlaying.artists && currentlyPlaying.artists.length > 0}
|
||||||
{#each currentlyPlaying.artists as artist, index}
|
{#each currentlyPlaying.artists as artist, index}
|
||||||
<a
|
<a
|
||||||
on:click={() => (expanded = false)}
|
on:click={() => (expanded = false)}
|
||||||
@@ -234,7 +237,7 @@
|
|||||||
<span class="mr-1 text-lg">,</span>
|
<span class="mr-1 text-lg">,</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{:else if 'uploader' in currentlyPlaying && currentlyPlaying.uploader}
|
{:else if currentlyPlaying.uploader}
|
||||||
<a
|
<a
|
||||||
on:click={() => (expanded = false)}
|
on:click={() => (expanded = false)}
|
||||||
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
|
class="line-clamp-1 flex-shrink-0 text-lg hover:underline focus:underline"
|
||||||
@@ -252,8 +255,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full items-center justify-center gap-2 text-2xl">
|
<div class="flex w-full items-center justify-center gap-2 text-2xl">
|
||||||
<button on:click={() => (shuffle = !shuffle)} class="aspect-square h-16">
|
<button on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())} class="aspect-square h-16">
|
||||||
<i class="fa-solid fa-shuffle" />
|
<i class="fa-solid {shuffled ? 'fa-shuffle' : 'fa-right-left'}" />
|
||||||
</button>
|
</button>
|
||||||
<button class="aspect-square h-16" on:click={() => $queue.previous()}>
|
<button class="aspect-square h-16" on:click={() => $queue.previous()}>
|
||||||
<i class="fa-solid fa-backward-step" />
|
<i class="fa-solid fa-backward-step" />
|
||||||
@@ -268,17 +271,23 @@
|
|||||||
<button class="aspect-square h-16" on:click={() => $queue.next()}>
|
<button class="aspect-square h-16" on:click={() => $queue.next()}>
|
||||||
<i class="fa-solid fa-forward-step" />
|
<i class="fa-solid fa-forward-step" />
|
||||||
</button>
|
</button>
|
||||||
<button on:click={() => (repeat = !repeat)} class="aspect-square h-16">
|
<button on:click={() => (loop = !loop)} class="aspect-square h-16">
|
||||||
<i class="fa-solid fa-repeat" />
|
<i class="fa-solid fa-repeat {loop ? 'text-lazuli-primary' : 'text-white'}" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<section class="flex items-center justify-end gap-2 text-xl">
|
<section class="flex items-center justify-end gap-2 text-xl">
|
||||||
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
<div id="volume-slider" class="flex h-10 flex-row-reverse items-center gap-2">
|
||||||
<button on:click={() => (muted = !muted)} class="aspect-square h-8">
|
<button on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))} class="aspect-square h-8">
|
||||||
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
<i class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'} w-full text-center" />
|
||||||
</button>
|
</button>
|
||||||
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
<div id="slider-wrapper" class="w-24 transition-all duration-500">
|
||||||
<Slider bind:value={volume} max={maxVolume} />
|
<Slider
|
||||||
|
bind:value={volume}
|
||||||
|
max={maxVolume}
|
||||||
|
on:seeked={() => {
|
||||||
|
if (volume > 0) localStorage.setItem('volume', volume.toString())
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="aspect-square h-8" on:click={() => (expanded = false)}>
|
<button class="aspect-square h-8" on:click={() => (expanded = false)}>
|
||||||
@@ -305,6 +314,7 @@
|
|||||||
on:ended={() => $queue.next()}
|
on:ended={() => $queue.next()}
|
||||||
on:error={() => setTimeout(() => audioElement.load(), 5000)}
|
on:error={() => setTimeout(() => audioElement.load(), 5000)}
|
||||||
src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
|
src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}"
|
||||||
|
{loop}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,45 +6,50 @@ export class Jellyfin implements Connection {
|
|||||||
public readonly id: string
|
public readonly id: string
|
||||||
private readonly userId: string
|
private readonly userId: string
|
||||||
private readonly jellyfinUserId: string
|
private readonly jellyfinUserId: string
|
||||||
private readonly serverUrl: string
|
|
||||||
private readonly accessToken: string
|
|
||||||
|
|
||||||
private readonly authHeader: Headers
|
private readonly services: JellyfinServices
|
||||||
|
private libraryManager?: JellyfinLibraryManager
|
||||||
|
|
||||||
constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
|
constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
|
||||||
this.id = id
|
this.id = id
|
||||||
this.userId = userId
|
this.userId = userId
|
||||||
this.jellyfinUserId = jellyfinUserId
|
this.jellyfinUserId = jellyfinUserId
|
||||||
this.serverUrl = serverUrl
|
|
||||||
this.accessToken = accessToken
|
|
||||||
|
|
||||||
this.authHeader = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` })
|
this.services = new JellyfinServices(this.id, serverUrl, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
public get library() {
|
||||||
|
if (!this.libraryManager) this.libraryManager = new JellyfinLibraryManager(this.jellyfinUserId, this.services)
|
||||||
|
|
||||||
|
return this.libraryManager
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getConnectionInfo() {
|
public async getConnectionInfo() {
|
||||||
const userUrl = new URL(`Users/${this.jellyfinUserId}`, this.serverUrl)
|
const userEndpoint = `Users/${this.jellyfinUserId}`
|
||||||
const systemUrl = new URL('System/Info', this.serverUrl)
|
const systemEndpoint = 'System/Info'
|
||||||
|
|
||||||
const getUserData = () =>
|
const getUserData = () =>
|
||||||
fetch(userUrl, { headers: this.authHeader })
|
this.services
|
||||||
|
.request(userEndpoint)
|
||||||
.then((response) => response.json() as Promise<JellyfinAPI.UserResponse>)
|
.then((response) => response.json() as Promise<JellyfinAPI.UserResponse>)
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
|
|
||||||
const getSystemData = () =>
|
const getSystemData = () =>
|
||||||
fetch(systemUrl, { headers: this.authHeader })
|
this.services
|
||||||
|
.request(systemEndpoint)
|
||||||
.then((response) => response.json() as Promise<JellyfinAPI.SystemResponse>)
|
.then((response) => response.json() as Promise<JellyfinAPI.SystemResponse>)
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
|
|
||||||
const [userData, systemData] = await Promise.all([getUserData(), getSystemData()])
|
const [userData, systemData] = await Promise.all([getUserData(), getSystemData()])
|
||||||
|
|
||||||
if (!userData) console.error(`Fetch to ${userUrl.toString()} failed`)
|
if (!userData) console.error(`Fetch to ${userEndpoint.toString()} failed`)
|
||||||
if (!systemData) console.error(`Fetch to ${systemUrl.toString()} failed`)
|
if (!systemData) console.error(`Fetch to ${systemEndpoint.toString()} failed`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
userId: this.userId,
|
userId: this.userId,
|
||||||
type: 'jellyfin',
|
type: 'jellyfin',
|
||||||
serverUrl: this.serverUrl,
|
serverUrl: this.services.serverUrl().toString(),
|
||||||
serverName: systemData?.ServerName,
|
serverName: systemData?.ServerName,
|
||||||
jellyfinUserId: this.jellyfinUserId,
|
jellyfinUserId: this.jellyfinUserId,
|
||||||
username: userData?.Name,
|
username: userData?.Name,
|
||||||
@@ -65,21 +70,20 @@ export class Jellyfin implements Connection {
|
|||||||
recursive: 'true',
|
recursive: 'true',
|
||||||
})
|
})
|
||||||
|
|
||||||
const searchURL = new URL(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl)
|
const searchResults = await this.services
|
||||||
const searchResponse = await fetch(searchURL, { headers: this.authHeader })
|
.request(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
|
||||||
if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL.toString())
|
.then((response) => response.json() as Promise<{ Items: (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[] }>)
|
||||||
const searchResults = (await searchResponse.json()).Items as (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[]
|
|
||||||
|
|
||||||
return searchResults.map((result) => {
|
return searchResults.Items.map((result) => {
|
||||||
switch (result.Type) {
|
switch (result.Type) {
|
||||||
case 'Audio':
|
case 'Audio':
|
||||||
return this.parseSong(result)
|
return this.services.parseSong(result)
|
||||||
case 'MusicAlbum':
|
case 'MusicAlbum':
|
||||||
return this.parseAlbum(result)
|
return this.services.parseAlbum(result)
|
||||||
case 'MusicArtist':
|
case 'MusicArtist':
|
||||||
return this.parseArtist(result)
|
return this.services.parseArtist(result)
|
||||||
case 'Playlist':
|
case 'Playlist':
|
||||||
return this.parsePlaylist(result)
|
return this.services.parsePlaylist(result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -93,11 +97,10 @@ export class Jellyfin implements Connection {
|
|||||||
limit: '10',
|
limit: '10',
|
||||||
})
|
})
|
||||||
|
|
||||||
const mostPlayedSongsURL = new URL(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl)
|
return this.services
|
||||||
|
.request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
|
||||||
const mostPlayed: { Items: JellyfinAPI.Song[] } = await fetch(mostPlayedSongsURL, { headers: this.authHeader }).then((response) => response.json())
|
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>)
|
||||||
|
.then((data) => data.Items.map((song) => this.services.parseSong(song)))
|
||||||
return mostPlayed.Items.map((song) => this.parseSong(song))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Figure out why seeking a jellyfin song takes so much longer than ytmusic (hls?)
|
// TODO: Figure out why seeking a jellyfin song takes so much longer than ytmusic (hls?)
|
||||||
@@ -111,27 +114,14 @@ export class Jellyfin implements Connection {
|
|||||||
userId: this.jellyfinUserId,
|
userId: this.jellyfinUserId,
|
||||||
})
|
})
|
||||||
|
|
||||||
const audioUrl = new URL(`Audio/${id}/universal?${audoSearchParams.toString()}`, this.serverUrl)
|
return this.services.request(`Audio/${id}/universal?${audoSearchParams.toString()}`, { headers, keepalive: true })
|
||||||
|
|
||||||
return fetch(audioUrl, { headers: Object.assign(headers, this.authHeader) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAlbum(id: string) {
|
public async getAlbum(id: string) {
|
||||||
const albumUrl = new URL(`/Users/${this.jellyfinUserId}/Items/${id}`, this.serverUrl)
|
return this.services
|
||||||
|
.request(`/Users/${this.jellyfinUserId}/Items/${id}`)
|
||||||
const album = await fetch(albumUrl, { headers: this.authHeader })
|
.then((response) => response.json() as Promise<JellyfinAPI.Album>)
|
||||||
.then((response) => {
|
.then(this.services.parseAlbum)
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`)
|
|
||||||
throw TypeError(`Invalid album ${id} of jellyfin connection ${this.id}`)
|
|
||||||
}
|
|
||||||
return response.json() as Promise<JellyfinAPI.Album>
|
|
||||||
})
|
|
||||||
.catch(() => null)
|
|
||||||
|
|
||||||
if (!album) throw Error(`Failed to fetch album ${id} of jellyfin connection ${this.id}`)
|
|
||||||
|
|
||||||
return this.parseAlbum(album)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAlbumItems(id: string) {
|
public async getAlbumItems(id: string) {
|
||||||
@@ -140,133 +130,35 @@ export class Jellyfin implements Connection {
|
|||||||
sortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
sortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
||||||
})
|
})
|
||||||
|
|
||||||
const albumItemsUrl = new URL(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl)
|
return this.services
|
||||||
|
.request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
|
||||||
const albumItems = await fetch(albumItemsUrl, { headers: this.authHeader })
|
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>)
|
||||||
.then((response) => {
|
.then((data) => data.Items.map(this.services.parseSong))
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`)
|
|
||||||
throw TypeError(`Invalid album ${id} of jellyfin connection ${this.id}`)
|
|
||||||
}
|
|
||||||
return response.json() as Promise<{ Items: JellyfinAPI.Song[] }>
|
|
||||||
})
|
|
||||||
.catch(() => null)
|
|
||||||
|
|
||||||
if (!albumItems) throw Error(`Failed to fetch album ${id} items of jellyfin connection ${this.id}`)
|
|
||||||
|
|
||||||
return albumItems.Items.map((item) => this.parseSong(item))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPlaylist(id: string) {
|
public async getPlaylist(id: string) {
|
||||||
const playlistUrl = new URL(`/Users/${this.jellyfinUserId}/Items/${id}`, this.serverUrl)
|
return this.services
|
||||||
|
.request(`/Users/${this.jellyfinUserId}/Items/${id}`)
|
||||||
const playlist = await fetch(playlistUrl, { headers: this.authHeader })
|
.then((response) => response.json() as Promise<JellyfinAPI.Playlist>)
|
||||||
.then((response) => {
|
.then(this.services.parsePlaylist)
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`)
|
|
||||||
throw TypeError(`Invalid playlist ${id} of jellyfin connection ${this.id}`)
|
|
||||||
}
|
|
||||||
return response.json() as Promise<JellyfinAPI.Playlist>
|
|
||||||
})
|
|
||||||
.catch(() => null)
|
|
||||||
|
|
||||||
if (!playlist) throw Error(`Failed to fetch playlist ${id} of jellyfin connection ${this.id}`)
|
|
||||||
|
|
||||||
return this.parsePlaylist(playlist)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPlaylistItems(id: string, startIndex?: number, limit?: number) {
|
public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }) {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
parentId: id,
|
parentId: id,
|
||||||
includeItemTypes: 'Audio',
|
includeItemTypes: 'Audio',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (startIndex) searchParams.append('startIndex', startIndex.toString())
|
if (options?.startIndex) searchParams.append('startIndex', options.startIndex.toString())
|
||||||
if (limit) searchParams.append('limit', limit.toString())
|
if (options?.limit) searchParams.append('limit', options.limit.toString())
|
||||||
|
|
||||||
const playlistItemsUrl = new URL(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl)
|
return this.services
|
||||||
|
.request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`)
|
||||||
const playlistItems = await fetch(playlistItemsUrl, { headers: this.authHeader })
|
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>)
|
||||||
.then((response) => {
|
.then((data) => data.Items.map(this.services.parseSong))
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`)
|
|
||||||
throw TypeError(`Invalid playlist ${id} of jellyfin connection ${this.id}`)
|
|
||||||
}
|
|
||||||
return response.json() as Promise<{ Items: JellyfinAPI.Song[] }>
|
|
||||||
})
|
|
||||||
.catch(() => null)
|
|
||||||
|
|
||||||
if (!playlistItems) throw Error(`Failed to fetch playlist ${id} items of jellyfin connection ${this.id}`)
|
|
||||||
|
|
||||||
return playlistItems.Items.map((item) => this.parseSong(item))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseSong = (song: JellyfinAPI.Song): Song => {
|
public static async authenticateByName(username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthenticationResponse> {
|
||||||
const thumbnailUrl = song.ImageTags?.Primary
|
|
||||||
? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).toString()
|
|
||||||
: song.AlbumPrimaryImageTag
|
|
||||||
? new URL(`Items/${song.AlbumId}/Images/Primary`, this.serverUrl).toString()
|
|
||||||
: jellyfinLogo
|
|
||||||
|
|
||||||
const artists: Song['artists'] = song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name }))
|
|
||||||
|
|
||||||
const album: Song['album'] = song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
connection: { id: this.id, type: 'jellyfin' },
|
|
||||||
id: song.Id,
|
|
||||||
name: song.Name,
|
|
||||||
type: 'song',
|
|
||||||
duration: Math.floor(song.RunTimeTicks / 10000000),
|
|
||||||
thumbnailUrl,
|
|
||||||
releaseDate: song.ProductionYear ? new Date(song.ProductionYear.toString()).toISOString() : undefined,
|
|
||||||
artists,
|
|
||||||
album,
|
|
||||||
isVideo: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseAlbum = (album: JellyfinAPI.Album): Album => {
|
|
||||||
const thumbnailUrl = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, this.serverUrl).toString() : jellyfinLogo
|
|
||||||
|
|
||||||
const artists: Album['artists'] = album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists'
|
|
||||||
|
|
||||||
return {
|
|
||||||
connection: { id: this.id, type: 'jellyfin' },
|
|
||||||
id: album.Id,
|
|
||||||
name: album.Name,
|
|
||||||
type: 'album',
|
|
||||||
thumbnailUrl,
|
|
||||||
artists,
|
|
||||||
releaseYear: album.ProductionYear?.toString(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseArtist(artist: JellyfinAPI.Artist): Artist {
|
|
||||||
const profilePicture = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, this.serverUrl).toString() : jellyfinLogo
|
|
||||||
|
|
||||||
return {
|
|
||||||
connection: { id: this.id, type: 'jellyfin' },
|
|
||||||
id: artist.Id,
|
|
||||||
name: artist.Name,
|
|
||||||
type: 'artist',
|
|
||||||
profilePicture,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parsePlaylist = (playlist: JellyfinAPI.Playlist): Playlist => {
|
|
||||||
const thumbnailUrl = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, this.serverUrl).toString() : jellyfinLogo
|
|
||||||
|
|
||||||
return {
|
|
||||||
connection: { id: this.id, type: 'jellyfin' },
|
|
||||||
id: playlist.Id,
|
|
||||||
name: playlist.Name,
|
|
||||||
type: 'playlist',
|
|
||||||
thumbnailUrl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static authenticateByName = async (username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthenticationResponse> => {
|
|
||||||
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString()
|
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString()
|
||||||
return fetch(authUrl, {
|
return fetch(authUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -289,6 +181,107 @@ export class Jellyfin implements Connection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class JellyfinServices {
|
||||||
|
private readonly connectionId: string
|
||||||
|
|
||||||
|
public readonly serverUrl: (endpoint?: string) => URL
|
||||||
|
public readonly request: (endpoint: string, options?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
|
constructor(connectionId: string, serverUrl: string, accessToken: string) {
|
||||||
|
this.connectionId = connectionId
|
||||||
|
|
||||||
|
this.serverUrl = (endpoint?: string) => new URL(endpoint ?? '', serverUrl)
|
||||||
|
|
||||||
|
this.request = async (endpoint: string, options?: RequestInit) => {
|
||||||
|
const headers = new Headers(options?.headers)
|
||||||
|
headers.set('Authorization', `MediaBrowser Token="${accessToken}"`)
|
||||||
|
delete options?.headers
|
||||||
|
return fetch(this.serverUrl(endpoint), { headers, ...options }).then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.connectionId} experienced and internal server error`)
|
||||||
|
throw TypeError(`Client side error in request to jellyfin server of connection ${this.connectionId}`)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBestThumbnail = (item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist) => {
|
||||||
|
const imageItemId = item.ImageTags?.Primary ? item.Id : 'AlbumPrimaryImageTag' in item && item.AlbumPrimaryImageTag ? item.AlbumId : undefined
|
||||||
|
return imageItemId ? this.serverUrl(`Items/${imageItemId}/Images/Primary`).toString() : jellyfinLogo
|
||||||
|
}
|
||||||
|
|
||||||
|
public parseSong = (song: JellyfinAPI.Song): Song => ({
|
||||||
|
connection: { id: this.connectionId, type: 'jellyfin' },
|
||||||
|
id: song.Id,
|
||||||
|
name: song.Name,
|
||||||
|
type: 'song',
|
||||||
|
duration: Math.floor(song.RunTimeTicks / 10000000),
|
||||||
|
thumbnailUrl: this.getBestThumbnail(song),
|
||||||
|
releaseDate: song.PremiereDate ? new Date(song.PremiereDate).toISOString() : undefined,
|
||||||
|
artists: song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name })),
|
||||||
|
album: song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined,
|
||||||
|
isVideo: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
public parseAlbum = (album: JellyfinAPI.Album): Album => ({
|
||||||
|
connection: { id: this.connectionId, type: 'jellyfin' },
|
||||||
|
id: album.Id,
|
||||||
|
name: album.Name,
|
||||||
|
type: 'album',
|
||||||
|
thumbnailUrl: this.getBestThumbnail(album),
|
||||||
|
artists: album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists',
|
||||||
|
releaseYear: album.ProductionYear?.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
public parseArtist = (artist: JellyfinAPI.Artist): Artist => ({
|
||||||
|
connection: { id: this.connectionId, type: 'jellyfin' },
|
||||||
|
id: artist.Id,
|
||||||
|
name: artist.Name,
|
||||||
|
type: 'artist',
|
||||||
|
profilePicture: this.getBestThumbnail(artist),
|
||||||
|
})
|
||||||
|
|
||||||
|
public parsePlaylist = (playlist: JellyfinAPI.Playlist): Playlist => ({
|
||||||
|
connection: { id: this.connectionId, type: 'jellyfin' },
|
||||||
|
id: playlist.Id,
|
||||||
|
name: playlist.Name,
|
||||||
|
type: 'playlist',
|
||||||
|
thumbnailUrl: this.getBestThumbnail(playlist),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
class JellyfinLibraryManager {
|
||||||
|
private readonly jellyfinUserId: string
|
||||||
|
private readonly services: JellyfinServices
|
||||||
|
|
||||||
|
constructor(jellyfinUserId: string, services: JellyfinServices) {
|
||||||
|
this.jellyfinUserId = jellyfinUserId
|
||||||
|
this.services = services
|
||||||
|
}
|
||||||
|
|
||||||
|
public async albums(): Promise<Album[]> {
|
||||||
|
return this.services
|
||||||
|
.request(`/Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=MusicAlbum&recursive=true`)
|
||||||
|
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Album[] }>)
|
||||||
|
.then((data) => data.Items.map(this.services.parseAlbum))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async artists(): Promise<Artist[]> {
|
||||||
|
return this.services
|
||||||
|
.request('/Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true')
|
||||||
|
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Artist[] }>)
|
||||||
|
.then((data) => data.Items.map(this.services.parseArtist))
|
||||||
|
}
|
||||||
|
|
||||||
|
public async playlists(): Promise<Playlist[]> {
|
||||||
|
return this.services
|
||||||
|
.request(`/Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=Playlist&recursive=true`)
|
||||||
|
.then((response) => response.json() as Promise<{ Items: JellyfinAPI.Playlist[] }>)
|
||||||
|
.then((data) => data.Items.map(this.services.parsePlaylist))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class JellyfinFetchError extends Error {
|
export class JellyfinFetchError extends Error {
|
||||||
public httpCode: number
|
public httpCode: number
|
||||||
public url: string
|
public url: string
|
||||||
@@ -306,6 +299,7 @@ declare namespace JellyfinAPI {
|
|||||||
Id: string
|
Id: string
|
||||||
Type: 'Audio'
|
Type: 'Audio'
|
||||||
RunTimeTicks: number
|
RunTimeTicks: number
|
||||||
|
PremiereDate?: string
|
||||||
ProductionYear?: number
|
ProductionYear?: number
|
||||||
ArtistItems?: {
|
ArtistItems?: {
|
||||||
Name: string
|
Name: string
|
||||||
@@ -328,6 +322,7 @@ declare namespace JellyfinAPI {
|
|||||||
Id: string
|
Id: string
|
||||||
Type: 'MusicAlbum'
|
Type: 'MusicAlbum'
|
||||||
RunTimeTicks: number
|
RunTimeTicks: number
|
||||||
|
PremiereDate?: string
|
||||||
ProductionYear?: number
|
ProductionYear?: number
|
||||||
ArtistItems?: {
|
ArtistItems?: {
|
||||||
Name: string
|
Name: string
|
||||||
|
|||||||
269
src/lib/server/youtube-music-types.d.ts
vendored
269
src/lib/server/youtube-music-types.d.ts
vendored
@@ -2,7 +2,7 @@
|
|||||||
// When scraping thumbnails from the YTMusic browse pages, there are two different types of images that can be returned,
|
// When scraping thumbnails from the YTMusic browse pages, there are two different types of images that can be returned,
|
||||||
// standard video thumbnais and auto-generated square thumbnails for propper releases. The auto-generated thumbanils we want to
|
// standard video thumbnais and auto-generated square thumbnails for propper releases. The auto-generated thumbanils we want to
|
||||||
// keep from the scrape because:
|
// keep from the scrape because:
|
||||||
// a) They can be easily scaled with ytmusic's weird fake query parameters (Ex: https://baseUrl=h1000)
|
// a) They can be easily scaled with ytmusic's weird fake query parameters (Ex: https://baseUrl=s1000)
|
||||||
// b) When fetched from the youtube data api it returns the 16:9 filled thumbnails like you would see in the standard yt player, we want the squares
|
// b) When fetched from the youtube data api it returns the 16:9 filled thumbnails like you would see in the standard yt player, we want the squares
|
||||||
//
|
//
|
||||||
// However when the thumbnail is for a video, we want to ignore it because the highest quality thumbnail will rarely be used in the ytmusic webapp
|
// However when the thumbnail is for a video, we want to ignore it because the highest quality thumbnail will rarely be used in the ytmusic webapp
|
||||||
@@ -64,12 +64,279 @@ export namespace InnerTube {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: 'playlist'
|
type: 'playlist'
|
||||||
|
thumbnailUrl: string
|
||||||
createdBy: {
|
createdBy: {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace Library {
|
||||||
|
interface AlbumResponse {
|
||||||
|
contents: {
|
||||||
|
singleColumnBrowseResultsRenderer: {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
tabRenderer: {
|
||||||
|
content: {
|
||||||
|
sectionListRenderer: {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
gridRenderer: {
|
||||||
|
items: Array<{
|
||||||
|
musicTwoRowItemRenderer: AlbumMusicTwoRowItemRenderer
|
||||||
|
}>
|
||||||
|
continuations?: [
|
||||||
|
{
|
||||||
|
nextContinuationData: {
|
||||||
|
continuation: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlbumContinuationResponse {
|
||||||
|
continuationContents: {
|
||||||
|
gridContinuation: {
|
||||||
|
items: Array<{
|
||||||
|
musicTwoRowItemRenderer: AlbumMusicTwoRowItemRenderer
|
||||||
|
}>
|
||||||
|
continuations?: [
|
||||||
|
{
|
||||||
|
nextContinuationData: {
|
||||||
|
continuation: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumMusicTwoRowItemRenderer = {
|
||||||
|
thumbnailRenderer: {
|
||||||
|
musicThumbnailRenderer: musicThumbnailRenderer
|
||||||
|
}
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subtitle: {
|
||||||
|
runs: Array<{
|
||||||
|
// Run's containing navigationEndpoints will be the album's artists. If many artists worked on an album a run will contain the text 'Various Artists'.
|
||||||
|
// The first run will always be 'Album', the last will always be the release year
|
||||||
|
text: string
|
||||||
|
navigationEndpoint?: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtistResponse {
|
||||||
|
contents: {
|
||||||
|
singleColumnBrowseResultsRenderer: {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
tabRenderer: {
|
||||||
|
content: {
|
||||||
|
sectionListRenderer: {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
musicShelfRenderer: {
|
||||||
|
contents: Array<{
|
||||||
|
musicResponsiveListItemRenderer: ArtistMusicResponsiveListItemRenderer
|
||||||
|
}>
|
||||||
|
continuations?: [
|
||||||
|
{
|
||||||
|
nextContinuationData: {
|
||||||
|
continuation: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtistContinuationResponse {
|
||||||
|
continuationContents: {
|
||||||
|
musicShelfContinuation: {
|
||||||
|
contents: Array<{
|
||||||
|
musicResponsiveListItemRenderer: ArtistMusicResponsiveListItemRenderer
|
||||||
|
}>
|
||||||
|
continuations?: [
|
||||||
|
{
|
||||||
|
nextContinuationData: {
|
||||||
|
continuation: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistMusicResponsiveListItemRenderer = {
|
||||||
|
thumbnail: {
|
||||||
|
musicThumbnailRenderer: musicThumbnailRenderer
|
||||||
|
}
|
||||||
|
flexColumns: [
|
||||||
|
{
|
||||||
|
musicResponsiveListItemFlexColumnRenderer: {
|
||||||
|
text: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistResponse {
|
||||||
|
contents: {
|
||||||
|
singleColumnBrowseResultsRenderer: {
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
tabRenderer: {
|
||||||
|
content: {
|
||||||
|
sectionListRenderer: {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
gridRenderer: {
|
||||||
|
items: Array<{
|
||||||
|
musicTwoRowItemRenderer:
|
||||||
|
| NewPlaylistMusicTwoRowItemRenderer
|
||||||
|
| LikedMusicPlaylistMusicTwoRowItemRenderer
|
||||||
|
| EpisodesPlaylistMusicTwoRowItemRenderer
|
||||||
|
| PlaylistMusicTwoRowItemRenderer
|
||||||
|
}>
|
||||||
|
continuations?: [
|
||||||
|
{
|
||||||
|
nextContinuationData: {
|
||||||
|
continuation: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistContinuationResponse {
|
||||||
|
continuationContents: {
|
||||||
|
gridContinuation: {
|
||||||
|
items: Array<{
|
||||||
|
musicTwoRowItemRenderer: NewPlaylistMusicTwoRowItemRenderer | LikedMusicPlaylistMusicTwoRowItemRenderer | EpisodesPlaylistMusicTwoRowItemRenderer | PlaylistMusicTwoRowItemRenderer
|
||||||
|
}>
|
||||||
|
continuations?: [
|
||||||
|
{
|
||||||
|
nextContinuationData: {
|
||||||
|
continuation: string
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewPlaylistMusicTwoRowItemRenderer = {
|
||||||
|
navigationEndpoint: {
|
||||||
|
createPlaylistEndpoint: Object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LikedMusicPlaylistMusicTwoRowItemRenderer = {
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: 'VLLM'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EpisodesPlaylistMusicTwoRowItemRenderer = {
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: 'VLSE'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaylistMusicTwoRowItemRenderer = {
|
||||||
|
thumbnailRenderer: {
|
||||||
|
musicThumbnailRenderer: musicThumbnailRenderer
|
||||||
|
}
|
||||||
|
title: {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
text: string
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subtitle: {
|
||||||
|
runs: Array<{
|
||||||
|
text: string
|
||||||
|
navigationEndpoint?: {
|
||||||
|
// If present, this run is the creator of the playlist
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
navigationEndpoint: {
|
||||||
|
browseEndpoint: {
|
||||||
|
browseId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
namespace Playlist {
|
namespace Playlist {
|
||||||
interface PlaylistResponse {
|
interface PlaylistResponse {
|
||||||
contents: {
|
contents: {
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { google, type youtube_v3 } from 'googleapis'
|
import { youtube, type youtube_v3 } from 'googleapis/build/src/apis/youtube'
|
||||||
import { DB } from './db'
|
import { DB } from './db'
|
||||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||||
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
||||||
import type { InnerTube } from './youtube-music-types'
|
import type { InnerTube } from './youtube-music-types'
|
||||||
|
|
||||||
const ytDataApi = google.youtube('v3')
|
const ytDataApi = youtube('v3')
|
||||||
|
|
||||||
const searchFilterParams = {
|
|
||||||
song: 'EgWKAQIIAWoMEA4QChADEAQQCRAF',
|
|
||||||
album: 'EgWKAQIYAWoMEA4QChADEAQQCRAF',
|
|
||||||
artist: 'EgWKAQIgAWoMEA4QChADEAQQCRAF',
|
|
||||||
playlist: 'Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
type ytMusicv1ApiRequestParams =
|
type ytMusicv1ApiRequestParams =
|
||||||
| {
|
| {
|
||||||
@@ -38,71 +31,31 @@ type ScrapedMediaItemMap<MediaItem> = MediaItem extends InnerTube.ScrapedSong
|
|||||||
? Playlist
|
? Playlist
|
||||||
: never
|
: never
|
||||||
|
|
||||||
|
// TODO: Throughout this method, whenever I extract the duration of a video I might want to subtract 1, the actual duration appears to always be one second less than what the duration lists.
|
||||||
export class YouTubeMusic implements Connection {
|
export class YouTubeMusic implements Connection {
|
||||||
public readonly id: string
|
public readonly id: string
|
||||||
private readonly userId: string
|
private readonly userId: string
|
||||||
private readonly ytUserId: string
|
private readonly youtubeUserId: string
|
||||||
private currentAccessToken: string
|
|
||||||
private readonly refreshToken: string
|
private readonly requestManager: YTRequestManager
|
||||||
private expiry: number
|
private libraryManager?: YTLibaryManager
|
||||||
|
|
||||||
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
|
constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) {
|
||||||
this.id = id
|
this.id = id
|
||||||
this.userId = userId
|
this.userId = userId
|
||||||
this.ytUserId = youtubeUserId
|
this.youtubeUserId = youtubeUserId
|
||||||
this.currentAccessToken = accessToken
|
|
||||||
this.refreshToken = refreshToken
|
this.requestManager = new YTRequestManager(id, accessToken, refreshToken, expiry)
|
||||||
this.expiry = expiry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private accessTokenRefreshRequest: Promise<string> | null = null
|
public get library() {
|
||||||
private get accessToken() {
|
if (!this.libraryManager) this.libraryManager = new YTLibaryManager(this.id, this.youtubeUserId, this.requestManager)
|
||||||
const refreshAccessToken = async () => {
|
|
||||||
const MAX_TRIES = 3
|
|
||||||
let tries = 0
|
|
||||||
const refreshDetails = { client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, client_secret: YOUTUBE_API_CLIENT_SECRET, refresh_token: this.refreshToken, grant_type: 'refresh_token' }
|
|
||||||
|
|
||||||
while (tries < MAX_TRIES) {
|
return this.libraryManager
|
||||||
++tries
|
|
||||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(refreshDetails),
|
|
||||||
}).catch((reason) => {
|
|
||||||
console.error(`Fetch to refresh endpoint failed: ${reason}`)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
if (!response || !response.ok) continue
|
|
||||||
|
|
||||||
const { access_token, expires_in } = await response.json()
|
|
||||||
const expiry = Date.now() + expires_in * 1000
|
|
||||||
return { accessToken: access_token as string, expiry }
|
|
||||||
}
|
|
||||||
|
|
||||||
throw Error(`Failed to refresh access tokens for YouTube Music connection: ${this.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.expiry > Date.now()) return new Promise<string>((resolve) => resolve(this.currentAccessToken))
|
|
||||||
|
|
||||||
if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
|
|
||||||
|
|
||||||
this.accessTokenRefreshRequest = refreshAccessToken()
|
|
||||||
.then(({ accessToken, expiry }) => {
|
|
||||||
DB.updateTokens(this.id, { accessToken, refreshToken: this.refreshToken, expiry })
|
|
||||||
this.currentAccessToken = accessToken
|
|
||||||
this.expiry = expiry
|
|
||||||
this.accessTokenRefreshRequest = null
|
|
||||||
return accessToken
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
this.accessTokenRefreshRequest = null
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
|
|
||||||
return this.accessTokenRefreshRequest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getConnectionInfo() {
|
public async getConnectionInfo() {
|
||||||
const access_token = await this.accessToken.catch(() => null)
|
const access_token = await this.requestManager.accessToken.catch(() => null)
|
||||||
|
|
||||||
let username: string | undefined, profilePicture: string | undefined
|
let username: string | undefined, profilePicture: string | undefined
|
||||||
if (access_token) {
|
if (access_token) {
|
||||||
@@ -112,55 +65,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
profilePicture = userChannel?.snippet?.thumbnails?.default?.url ?? undefined
|
profilePicture = userChannel?.snippet?.thumbnails?.default?.url ?? undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id: this.id, userId: this.userId, type: 'youtube-music', youtubeUserId: this.ytUserId, username, profilePicture } satisfies ConnectionInfo
|
return { id: this.id, userId: this.userId, type: 'youtube-music', youtubeUserId: this.youtubeUserId, username, profilePicture } satisfies ConnectionInfo
|
||||||
}
|
|
||||||
|
|
||||||
private async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) {
|
|
||||||
const headers = new Headers({
|
|
||||||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
|
|
||||||
authorization: `Bearer ${await this.accessToken}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentDate = new Date()
|
|
||||||
const year = currentDate.getUTCFullYear().toString()
|
|
||||||
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
|
|
||||||
const day = currentDate.getUTCDate().toString().padStart(2, '0')
|
|
||||||
|
|
||||||
const context = {
|
|
||||||
client: {
|
|
||||||
clientName: 'WEB_REMIX',
|
|
||||||
clientVersion: `1.${year + month + day}.01.00`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
let url: string
|
|
||||||
let body: Record<string, any>
|
|
||||||
|
|
||||||
switch (requestDetails.type) {
|
|
||||||
case 'browse':
|
|
||||||
url = 'https://music.youtube.com/youtubei/v1/browse'
|
|
||||||
body = {
|
|
||||||
browseId: requestDetails.browseId,
|
|
||||||
context,
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'search':
|
|
||||||
url = 'https://music.youtube.com/youtubei/v1/search'
|
|
||||||
body = {
|
|
||||||
query: requestDetails.searchTerm,
|
|
||||||
filter: requestDetails.filter ? searchFilterParams[requestDetails.filter] : undefined,
|
|
||||||
context,
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'continuation':
|
|
||||||
url = `https://music.youtube.com/youtubei/v1/browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`
|
|
||||||
body = {
|
|
||||||
context,
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Figure out why this still breaks sometimes (Figured out one cause: "Episodes" can appear as videos)
|
// TODO: Figure out why this still breaks sometimes (Figured out one cause: "Episodes" can appear as videos)
|
||||||
@@ -173,7 +78,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
// Figure out how to handle Library and Uploads
|
// Figure out how to handle Library and Uploads
|
||||||
// Depending on how I want to handle the playlist & library sync feature
|
// Depending on how I want to handle the playlist & library sync feature
|
||||||
|
|
||||||
const searchResulsts = (await this.ytMusicv1ApiRequest({ type: 'search', searchTerm, filter }).then((response) => response.json())) as InnerTube.SearchResponse
|
const searchResulsts = (await this.requestManager.ytMusicv1ApiRequest({ type: 'search', searchTerm, filter }).then((response) => response.json())) as InnerTube.SearchResponse
|
||||||
|
|
||||||
const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||||
|
|
||||||
@@ -221,7 +126,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
|
|
||||||
// TODO: Figure out why this still breaks sometimes (Figured out one cause: "Episodes" can appear as videos)
|
// TODO: Figure out why this still breaks sometimes (Figured out one cause: "Episodes" can appear as videos)
|
||||||
public async getRecommendations() {
|
public async getRecommendations() {
|
||||||
const homeResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse
|
const homeResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse
|
||||||
|
|
||||||
const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
|
||||||
|
|
||||||
@@ -263,7 +168,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', {
|
const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', {
|
||||||
headers: {
|
headers: {
|
||||||
// 'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', <-- I thought this was necessary but it appears it might not be?
|
// 'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', <-- I thought this was necessary but it appears it might not be?
|
||||||
authorization: `Bearer ${await this.accessToken}`, // * Including the access token is what enables access to premium content
|
authorization: `Bearer ${await this.requestManager.accessToken}`, // * Including the access token is what enables access to premium content
|
||||||
},
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -308,20 +213,20 @@ export class YouTubeMusic implements Connection {
|
|||||||
|
|
||||||
const hqAudioFormat = audioOnlyFormats.reduce((previous, current) => (previous.bitrate > current.bitrate ? previous : current))
|
const hqAudioFormat = audioOnlyFormats.reduce((previous, current) => (previous.bitrate > current.bitrate ? previous : current))
|
||||||
|
|
||||||
return fetch(hqAudioFormat.url, { headers })
|
return fetch(hqAudioFormat.url, { headers, keepalive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param id The browseId of the album
|
* @param id The browseId of the album
|
||||||
*/
|
*/
|
||||||
public async getAlbum(id: string): Promise<Album> {
|
public async getAlbum(id: string): Promise<Album> {
|
||||||
const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
|
const albumResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
|
||||||
|
|
||||||
const header = albumResponse.header.musicDetailHeaderRenderer
|
const header = albumResponse.header.musicDetailHeaderRenderer
|
||||||
|
|
||||||
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
|
||||||
const name = header.title.runs[0].text,
|
const name = header.title.runs[0].text,
|
||||||
thumbnailUrl = cleanThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails[0].url)
|
thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
let artists: Album['artists'] = []
|
let artists: Album['artists'] = []
|
||||||
for (const run of header.subtitle.runs) {
|
for (const run of header.subtitle.runs) {
|
||||||
@@ -344,7 +249,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
* @param id The browseId of the album
|
* @param id The browseId of the album
|
||||||
*/
|
*/
|
||||||
public async getAlbumItems(id: string): Promise<Song[]> {
|
public async getAlbumItems(id: string): Promise<Song[]> {
|
||||||
const albumResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
|
const albumResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: id }).then((response) => response.json())) as InnerTube.Album.AlbumResponse
|
||||||
|
|
||||||
const header = albumResponse.header.musicDetailHeaderRenderer
|
const header = albumResponse.header.musicDetailHeaderRenderer
|
||||||
|
|
||||||
@@ -352,7 +257,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
let continuation = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.continuations?.[0].nextContinuationData.continuation
|
let continuation = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
|
||||||
const thumbnailUrl = cleanThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails[0].url)
|
const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails)
|
||||||
const album: Song['album'] = { id, name: header.title.runs[0].text }
|
const album: Song['album'] = { id, name: header.title.runs[0].text }
|
||||||
|
|
||||||
const albumArtists = header.subtitle.runs
|
const albumArtists = header.subtitle.runs
|
||||||
@@ -360,7 +265,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
.map((run) => ({ id: run.navigationEndpoint!.browseEndpoint.browseId, name: run.text }))
|
.map((run) => ({ id: run.navigationEndpoint!.browseEndpoint.browseId, name: run.text }))
|
||||||
|
|
||||||
while (continuation) {
|
while (continuation) {
|
||||||
const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Album.ContinuationResponse
|
const continuationResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Album.ContinuationResponse
|
||||||
|
|
||||||
contents.push(...continuationResponse.continuationContents.musicShelfContinuation.contents)
|
contents.push(...continuationResponse.continuationContents.musicShelfContinuation.contents)
|
||||||
continuation = continuationResponse.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
|
continuation = continuationResponse.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
@@ -372,7 +277,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
const dividedItems = []
|
const dividedItems = []
|
||||||
for (let i = 0; i < playableItems.length; i += 50) dividedItems.push(playableItems.slice(i, i + 50))
|
for (let i = 0; i < playableItems.length; i += 50) dividedItems.push(playableItems.slice(i, i + 50))
|
||||||
|
|
||||||
const access_token = await this.accessToken
|
const access_token = await this.requestManager.accessToken
|
||||||
const videoSchemas = await Promise.all(
|
const videoSchemas = await Promise.all(
|
||||||
dividedItems.map((chunk) =>
|
dividedItems.map((chunk) =>
|
||||||
ytDataApi.videos.list({
|
ytDataApi.videos.list({
|
||||||
@@ -400,7 +305,11 @@ export class YouTubeMusic implements Connection {
|
|||||||
|
|
||||||
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
|
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
|
||||||
|
|
||||||
const artists = col1.musicResponsiveListItemFlexColumnRenderer.text.runs?.map((run) => ({ id: run.navigationEndpoint?.browseEndpoint.browseId ?? videoChannelMap.get(id)!, name: run.text })) ?? albumArtists
|
const artists =
|
||||||
|
col1.musicResponsiveListItemFlexColumnRenderer.text.runs?.map((run) => ({
|
||||||
|
id: run.navigationEndpoint?.browseEndpoint.browseId ?? videoChannelMap.get(id)!,
|
||||||
|
name: run.text,
|
||||||
|
})) ?? albumArtists
|
||||||
|
|
||||||
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo }
|
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo }
|
||||||
})
|
})
|
||||||
@@ -410,7 +319,8 @@ export class YouTubeMusic implements Connection {
|
|||||||
* @param id The id of the playlist (not the browseId!).
|
* @param id The id of the playlist (not the browseId!).
|
||||||
*/
|
*/
|
||||||
public async getPlaylist(id: string): Promise<Playlist> {
|
public async getPlaylist(id: string): Promise<Playlist> {
|
||||||
const playlistResponse = await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })
|
const playlistResponse = await this.requestManager
|
||||||
|
.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })
|
||||||
.then((response) => response.json() as Promise<InnerTube.Playlist.PlaylistResponse | InnerTube.Playlist.PlaylistErrorResponse>)
|
.then((response) => response.json() as Promise<InnerTube.Playlist.PlaylistResponse | InnerTube.Playlist.PlaylistErrorResponse>)
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
|
|
||||||
@@ -432,13 +342,11 @@ export class YouTubeMusic implements Connection {
|
|||||||
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
|
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
|
||||||
const name = header.title.runs[0].text
|
const name = header.title.runs[0].text
|
||||||
|
|
||||||
const thumbnailUrl = cleanThumbnailUrl(
|
const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails)
|
||||||
header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url, // This is because sometimes the thumbnails for playlists can be video thumbnails
|
|
||||||
)
|
|
||||||
|
|
||||||
let createdBy: Playlist['createdBy']
|
let createdBy: Playlist['createdBy']
|
||||||
header.subtitle.runs.forEach((run) => {
|
header.subtitle.runs.forEach((run) => {
|
||||||
if (run.navigationEndpoint?.browseEndpoint.browseId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
if (run.navigationEndpoint && run.navigationEndpoint.browseEndpoint.browseId !== this.youtubeUserId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
||||||
})
|
})
|
||||||
|
|
||||||
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
|
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
|
||||||
@@ -449,8 +357,12 @@ export class YouTubeMusic implements Connection {
|
|||||||
* @param startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results
|
* @param startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results
|
||||||
* @param limit The maximum number of playlist items to return
|
* @param limit The maximum number of playlist items to return
|
||||||
*/
|
*/
|
||||||
public async getPlaylistItems(id: string, startIndex?: number, limit?: number): Promise<Song[]> {
|
public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }): Promise<Song[]> {
|
||||||
const playlistResponse = await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })
|
const startIndex = options?.startIndex,
|
||||||
|
limit = options?.limit
|
||||||
|
|
||||||
|
const playlistResponse = await this.requestManager
|
||||||
|
.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })
|
||||||
.then((response) => response.json() as Promise<InnerTube.Playlist.PlaylistResponse | InnerTube.Playlist.PlaylistErrorResponse>)
|
.then((response) => response.json() as Promise<InnerTube.Playlist.PlaylistResponse | InnerTube.Playlist.PlaylistErrorResponse>)
|
||||||
.catch(() => null)
|
.catch(() => null)
|
||||||
|
|
||||||
@@ -472,7 +384,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation
|
playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
while (continuation && (!limit || playableContents.length < (startIndex ?? 0) + limit)) {
|
while (continuation && (!limit || playableContents.length < (startIndex ?? 0) + limit)) {
|
||||||
const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Playlist.ContinuationResponse
|
const continuationResponse = (await this.requestManager.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Playlist.ContinuationResponse
|
||||||
const playableContinuationContents = continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents.filter(
|
const playableContinuationContents = continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents.filter(
|
||||||
(item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined,
|
(item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined,
|
||||||
)
|
)
|
||||||
@@ -490,7 +402,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
|
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
|
||||||
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
||||||
|
|
||||||
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
const thumbnailUrl = isVideo ? undefined : extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
|
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
|
||||||
|
|
||||||
const col2run = col2.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
|
const col2run = col2.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
|
||||||
@@ -514,7 +426,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
const dividedItems = []
|
const dividedItems = []
|
||||||
for (let i = 0; i < scrapedItems.length; i += 50) dividedItems.push(scrapedItems.slice(i, i + 50))
|
for (let i = 0; i < scrapedItems.length; i += 50) dividedItems.push(scrapedItems.slice(i, i + 50))
|
||||||
|
|
||||||
const access_token = await this.accessToken
|
const access_token = await this.requestManager.accessToken
|
||||||
const videoSchemaMap = new Map<string, youtube_v3.Schema$Video>()
|
const videoSchemaMap = new Map<string, youtube_v3.Schema$Video>()
|
||||||
const videoSchemas = (await Promise.all(dividedItems.map((chunk) => ytDataApi.videos.list({ part: ['snippet'], id: chunk.map((item) => item.id), access_token })))).map((response) => response.data.items!).flat()
|
const videoSchemas = (await Promise.all(dividedItems.map((chunk) => ytDataApi.videos.list({ part: ['snippet'], id: chunk.map((item) => item.id), access_token })))).map((response) => response.data.items!).flat()
|
||||||
videoSchemas.forEach((schema) => videoSchemaMap.set(schema.id!, schema))
|
videoSchemas.forEach((schema) => videoSchemaMap.set(schema.id!, schema))
|
||||||
@@ -538,8 +450,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
|
|
||||||
private async scrapedToMediaItems<T extends (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[]>(scrapedItems: T): Promise<ScrapedMediaItemMap<T[number]>[]> {
|
private async scrapedToMediaItems<T extends (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[]>(scrapedItems: T): Promise<ScrapedMediaItemMap<T[number]>[]> {
|
||||||
const songIds = new Set<string>(),
|
const songIds = new Set<string>(),
|
||||||
albumIds = new Set<string>(),
|
albumIds = new Set<string>()
|
||||||
playlistIds = new Set<string>()
|
|
||||||
|
|
||||||
scrapedItems.forEach((item) => {
|
scrapedItems.forEach((item) => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
@@ -547,12 +458,6 @@ export class YouTubeMusic implements Connection {
|
|||||||
songIds.add(item.id)
|
songIds.add(item.id)
|
||||||
if (item.album?.id && !item.album.name) albumIds.add(item.album.id) // This is here because sometimes it is not possible to get the album name directly from a page, only the id
|
if (item.album?.id && !item.album.name) albumIds.add(item.album.id) // This is here because sometimes it is not possible to get the album name directly from a page, only the id
|
||||||
break
|
break
|
||||||
case 'album':
|
|
||||||
albumIds.add(item.id)
|
|
||||||
break
|
|
||||||
case 'playlist':
|
|
||||||
playlistIds.add(item.id)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -560,7 +465,7 @@ export class YouTubeMusic implements Connection {
|
|||||||
const dividedIds: string[][] = []
|
const dividedIds: string[][] = []
|
||||||
for (let i = 0; i < songIdArray.length; i += 50) dividedIds.push(songIdArray.slice(i, i + 50))
|
for (let i = 0; i < songIdArray.length; i += 50) dividedIds.push(songIdArray.slice(i, i + 50))
|
||||||
|
|
||||||
const access_token = await this.accessToken
|
const access_token = await this.requestManager.accessToken
|
||||||
|
|
||||||
const getSongDetails = () =>
|
const getSongDetails = () =>
|
||||||
Promise.all(dividedIds.map((idsChunk) => ytDataApi.videos.list({ part: ['snippet', 'contentDetails'], id: idsChunk, access_token }))).then((responses) =>
|
Promise.all(dividedIds.map((idsChunk) => ytDataApi.videos.list({ part: ['snippet', 'contentDetails'], id: idsChunk, access_token }))).then((responses) =>
|
||||||
@@ -568,16 +473,13 @@ export class YouTubeMusic implements Connection {
|
|||||||
)
|
)
|
||||||
// Oh FFS. Despite nothing documenting it ^this api can only query a maximum of 50 ids at a time. Addtionally, if you exceed that limit, it doesn't even give you the correct error, it says some nonsense about an invalid filter paramerter. FML.
|
// Oh FFS. Despite nothing documenting it ^this api can only query a maximum of 50 ids at a time. Addtionally, if you exceed that limit, it doesn't even give you the correct error, it says some nonsense about an invalid filter paramerter. FML.
|
||||||
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
|
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
|
||||||
const getPlaylistDetails = () => Promise.all(Array.from(playlistIds).map((id) => this.getPlaylist(id)))
|
|
||||||
|
|
||||||
const [songDetails, albumDetails, playlistDetails] = await Promise.all([getSongDetails(), getAlbumDetails(), getPlaylistDetails()])
|
const [songDetails, albumDetails] = await Promise.all([getSongDetails(), getAlbumDetails()])
|
||||||
const songDetailsMap = new Map<string, youtube_v3.Schema$Video>(),
|
const songDetailsMap = new Map<string, youtube_v3.Schema$Video>(),
|
||||||
albumDetailsMap = new Map<string, Album>(),
|
albumDetailsMap = new Map<string, Album>()
|
||||||
playlistDetailsMap = new Map<string, Playlist>()
|
|
||||||
|
|
||||||
songDetails.forEach((item) => songDetailsMap.set(item.id!, item))
|
songDetails.forEach((item) => songDetailsMap.set(item.id!, item))
|
||||||
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
|
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
|
||||||
playlistDetails.forEach((playlist) => playlistDetailsMap.set(playlist.id, playlist))
|
|
||||||
|
|
||||||
const connection = { id: this.id, type: 'youtube-music' } satisfies (Song | Album | Artist | Playlist)['connection']
|
const connection = { id: this.id, type: 'youtube-music' } satisfies (Song | Album | Artist | Playlist)['connection']
|
||||||
|
|
||||||
@@ -591,21 +493,25 @@ export class YouTubeMusic implements Connection {
|
|||||||
const thumbnails = songDetails.snippet?.thumbnails!
|
const thumbnails = songDetails.snippet?.thumbnails!
|
||||||
const thumbnailUrl = item.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
|
const thumbnailUrl = item.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
|
||||||
|
|
||||||
const releaseDate = new Date(songDetails.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? songDetails.snippet?.publishedAt!).toISOString()
|
const songAlbum = item.album?.id ? albumDetailsMap.get(item.album.id)! : undefined
|
||||||
|
const album = songAlbum ? { id: songAlbum.id, name: songAlbum.name } : undefined
|
||||||
|
|
||||||
let album: Song['album']
|
const releaseDate = new Date(songDetails.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? songDetails.snippet?.publishedAt!).toISOString()
|
||||||
if (item.album?.id) {
|
|
||||||
const albumName = item.album.name ? item.album.name : albumDetailsMap.get(item.album.id)!.name
|
|
||||||
album = { id: item.album.id, name: albumName }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo, uploader } satisfies Song
|
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo, uploader } satisfies Song
|
||||||
case 'album':
|
case 'album':
|
||||||
return albumDetailsMap.get(item.id)! satisfies Album
|
const releaseYear = albumDetailsMap.get(item.id)?.releaseYear // For in the unlikely event that and album got added by a song
|
||||||
|
// ? Honestly, I don't think it is worth it to send out a request to the album endpoint for every album just to get the release year.
|
||||||
|
// ? Maybe it will be justifyable in the future if I decide to add more details to the album type that can only be retrieved from the album endpoint.
|
||||||
|
// ? I guess as long as it's at most a dozen requests or so each time it's fine. But when I get to things larger queries like a user's library, this could become very bad very fast.
|
||||||
|
// ? Maybe I should add a "fields" paramter to the album, artist, and playlist types that can include addtional, but not necessary info like release year that can be requested in
|
||||||
|
// ? the specific methods, but left out for large query methods like this.
|
||||||
|
return Object.assign(item, { connection, releaseYear }) satisfies Album
|
||||||
case 'artist':
|
case 'artist':
|
||||||
return { connection, id: item.id, name: item.name, type: 'artist', profilePicture: item.profilePicture } satisfies Artist
|
return Object.assign(item, { connection }) satisfies Artist
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
return playlistDetailsMap.get(item.id)! satisfies Playlist
|
// * If there are ever problems with playlist thumbanails being incorrect (black bars, etc.) look into using the official api to get playlist thumbnails (getPlaylist() is inefficient)
|
||||||
|
return Object.assign(item, { connection }) satisfies Playlist
|
||||||
}
|
}
|
||||||
}) as ScrapedMediaItemMap<T[number]>[]
|
}) as ScrapedMediaItemMap<T[number]>[]
|
||||||
}
|
}
|
||||||
@@ -638,7 +544,7 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
|
|||||||
if ('watchEndpoint' in rowContent.navigationEndpoint) {
|
if ('watchEndpoint' in rowContent.navigationEndpoint) {
|
||||||
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
|
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
|
||||||
const isVideo = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
const isVideo = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
||||||
const thumbnailUrl: InnerTube.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
const thumbnailUrl: InnerTube.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : extractLargestThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
let albumId: string | undefined
|
let albumId: string | undefined
|
||||||
rowContent.menu?.menuRenderer.items.forEach((menuOption) => {
|
rowContent.menu?.menuRenderer.items.forEach((menuOption) => {
|
||||||
@@ -657,17 +563,16 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
|
|||||||
|
|
||||||
const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||||
const id = rowContent.navigationEndpoint.browseEndpoint.browseId
|
const id = rowContent.navigationEndpoint.browseEndpoint.browseId
|
||||||
|
const image = extractLargestThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
switch (pageType) {
|
switch (pageType) {
|
||||||
case 'MUSIC_PAGE_TYPE_ALBUM':
|
case 'MUSIC_PAGE_TYPE_ALBUM':
|
||||||
const thumbnailUrl = cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
return { id, name, type: 'album', artists, thumbnailUrl: image } satisfies InnerTube.ScrapedAlbum
|
||||||
return { id, name, type: 'album', artists, thumbnailUrl } satisfies InnerTube.ScrapedAlbum
|
|
||||||
case 'MUSIC_PAGE_TYPE_ARTIST':
|
case 'MUSIC_PAGE_TYPE_ARTIST':
|
||||||
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
|
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
|
||||||
const profilePicture = cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
return { id, name, type: 'artist', profilePicture: image } satisfies InnerTube.ScrapedArtist
|
||||||
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
|
|
||||||
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
||||||
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
return { id: id.slice(2), name, type: 'playlist', thumbnailUrl: image, createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
||||||
default:
|
default:
|
||||||
throw Error('Unexpected twoRowItem type: ' + pageType)
|
throw Error('Unexpected twoRowItem type: ' + pageType)
|
||||||
}
|
}
|
||||||
@@ -705,7 +610,7 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
|
|||||||
const isVideo =
|
const isVideo =
|
||||||
listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !==
|
listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !==
|
||||||
'MUSIC_VIDEO_TYPE_ATV'
|
'MUSIC_VIDEO_TYPE_ATV'
|
||||||
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
const thumbnailUrl = isVideo ? undefined : extractLargestThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
|
const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
|
||||||
const album =
|
const album =
|
||||||
@@ -718,17 +623,16 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
|
|||||||
|
|
||||||
const id = listContent.navigationEndpoint.browseEndpoint.browseId
|
const id = listContent.navigationEndpoint.browseEndpoint.browseId
|
||||||
const pageType = listContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
const pageType = listContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||||
|
const image = extractLargestThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
switch (pageType) {
|
switch (pageType) {
|
||||||
case 'MUSIC_PAGE_TYPE_ALBUM':
|
case 'MUSIC_PAGE_TYPE_ALBUM':
|
||||||
const thumbnailUrl = cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
return { id, name, type: 'album', thumbnailUrl: image, artists } satisfies InnerTube.ScrapedAlbum
|
||||||
return { id, name, type: 'album', thumbnailUrl, artists } satisfies InnerTube.ScrapedAlbum
|
|
||||||
case 'MUSIC_PAGE_TYPE_ARTIST':
|
case 'MUSIC_PAGE_TYPE_ARTIST':
|
||||||
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
|
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
|
||||||
const profilePicture = cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
return { id, name, type: 'artist', profilePicture: image } satisfies InnerTube.ScrapedArtist
|
||||||
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
|
|
||||||
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
||||||
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
return { id: id.slice(2), name, type: 'playlist', thumbnailUrl: image, createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
||||||
default:
|
default:
|
||||||
throw Error('Unexpected responsiveListItem type: ' + pageType)
|
throw Error('Unexpected responsiveListItem type: ' + pageType)
|
||||||
}
|
}
|
||||||
@@ -763,28 +667,254 @@ function parseMusicCardShelfRenderer(cardContent: InnerTube.musicCardShelfRender
|
|||||||
if ('watchEndpoint' in navigationEndpoint) {
|
if ('watchEndpoint' in navigationEndpoint) {
|
||||||
const id = navigationEndpoint.watchEndpoint.videoId
|
const id = navigationEndpoint.watchEndpoint.videoId
|
||||||
const isVideo = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
const isVideo = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
|
||||||
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
const thumbnailUrl = isVideo ? undefined : extractLargestThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.ScrapedSong
|
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.ScrapedSong
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
const pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
|
||||||
const id = navigationEndpoint.browseEndpoint.browseId
|
const id = navigationEndpoint.browseEndpoint.browseId
|
||||||
|
const image = extractLargestThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
switch (pageType) {
|
switch (pageType) {
|
||||||
case 'MUSIC_PAGE_TYPE_ALBUM':
|
case 'MUSIC_PAGE_TYPE_ALBUM':
|
||||||
const thumbnailUrl = cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
return { id, name, type: 'album', thumbnailUrl: image, artists } satisfies InnerTube.ScrapedAlbum
|
||||||
return { id, name, type: 'album', thumbnailUrl, artists } satisfies InnerTube.ScrapedAlbum
|
|
||||||
case 'MUSIC_PAGE_TYPE_ARTIST':
|
case 'MUSIC_PAGE_TYPE_ARTIST':
|
||||||
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
|
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
|
||||||
const profilePicture = cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
|
return { id, name, type: 'artist', profilePicture: image } satisfies InnerTube.ScrapedArtist
|
||||||
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.ScrapedArtist
|
|
||||||
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
case 'MUSIC_PAGE_TYPE_PLAYLIST':
|
||||||
return { id: id.slice(2), name, type: 'playlist', createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
return { id: id.slice(2), name, type: 'playlist', thumbnailUrl: image, createdBy: creator! } satisfies InnerTube.ScrapedPlaylist
|
||||||
default:
|
default:
|
||||||
throw Error('Unexpected musicCardShelf type: ' + pageType)
|
throw Error('Unexpected musicCardShelf type: ' + pageType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class YTRequestManager {
|
||||||
|
private readonly connectionId: string
|
||||||
|
private currentAccessToken: string
|
||||||
|
private readonly refreshToken: string
|
||||||
|
private expiry: number
|
||||||
|
|
||||||
|
private readonly searchFilterParams = {
|
||||||
|
song: 'EgWKAQIIAWoMEA4QChADEAQQCRAF',
|
||||||
|
album: 'EgWKAQIYAWoMEA4QChADEAQQCRAF',
|
||||||
|
artist: 'EgWKAQIgAWoMEA4QChADEAQQCRAF',
|
||||||
|
playlist: 'Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
constructor(connectionId: string, accessToken: string, refreshToken: string, expiry: number) {
|
||||||
|
this.connectionId = connectionId
|
||||||
|
this.currentAccessToken = accessToken
|
||||||
|
this.refreshToken = refreshToken
|
||||||
|
this.expiry = expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
private accessTokenRefreshRequest: Promise<string> | null = null
|
||||||
|
public get accessToken() {
|
||||||
|
const refreshAccessToken = async () => {
|
||||||
|
const MAX_TRIES = 3
|
||||||
|
let tries = 0
|
||||||
|
const refreshDetails = {
|
||||||
|
client_id: PUBLIC_YOUTUBE_API_CLIENT_ID,
|
||||||
|
client_secret: YOUTUBE_API_CLIENT_SECRET,
|
||||||
|
refresh_token: this.refreshToken,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
}
|
||||||
|
|
||||||
|
while (tries < MAX_TRIES) {
|
||||||
|
++tries
|
||||||
|
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(refreshDetails),
|
||||||
|
}).catch(() => null)
|
||||||
|
if (!response || !response.ok) continue
|
||||||
|
|
||||||
|
const { access_token, expires_in } = await response.json()
|
||||||
|
const expiry = Date.now() + expires_in * 1000
|
||||||
|
return { accessToken: access_token as string, expiry }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Error(`Failed to refresh access tokens for YouTube Music connection: ${this.connectionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.expiry > Date.now()) return new Promise<string>((resolve) => resolve(this.currentAccessToken))
|
||||||
|
|
||||||
|
if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
|
||||||
|
|
||||||
|
this.accessTokenRefreshRequest = refreshAccessToken()
|
||||||
|
.then(({ accessToken, expiry }) => {
|
||||||
|
DB.updateTokens(this.connectionId, { accessToken, refreshToken: this.refreshToken, expiry })
|
||||||
|
this.currentAccessToken = accessToken
|
||||||
|
this.expiry = expiry
|
||||||
|
this.accessTokenRefreshRequest = null
|
||||||
|
return accessToken
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
this.accessTokenRefreshRequest = null
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.accessTokenRefreshRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) {
|
||||||
|
const headers = new Headers({
|
||||||
|
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
|
||||||
|
authorization: `Bearer ${await this.accessToken}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentDate = new Date()
|
||||||
|
const year = currentDate.getUTCFullYear().toString()
|
||||||
|
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
|
||||||
|
const day = currentDate.getUTCDate().toString().padStart(2, '0')
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
client: {
|
||||||
|
clientName: 'WEB_REMIX',
|
||||||
|
clientVersion: `1.${year + month + day}.01.00`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: string
|
||||||
|
let body: Record<string, any>
|
||||||
|
|
||||||
|
switch (requestDetails.type) {
|
||||||
|
case 'browse':
|
||||||
|
url = 'https://music.youtube.com/youtubei/v1/browse'
|
||||||
|
body = {
|
||||||
|
browseId: requestDetails.browseId,
|
||||||
|
context,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'search':
|
||||||
|
url = 'https://music.youtube.com/youtubei/v1/search'
|
||||||
|
body = {
|
||||||
|
query: requestDetails.searchTerm,
|
||||||
|
filter: requestDetails.filter ? this.searchFilterParams[requestDetails.filter] : undefined,
|
||||||
|
context,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'continuation':
|
||||||
|
url = `https://music.youtube.com/youtubei/v1/browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`
|
||||||
|
body = {
|
||||||
|
context,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class YTLibaryManager {
|
||||||
|
private readonly connectionId: string
|
||||||
|
private readonly requestManager: YTRequestManager
|
||||||
|
private readonly youtubeUserId: string
|
||||||
|
|
||||||
|
constructor(connectionId: string, youtubeUserId: string, requestManager: YTRequestManager) {
|
||||||
|
this.connectionId = connectionId
|
||||||
|
this.requestManager = requestManager
|
||||||
|
this.youtubeUserId = youtubeUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
public async albums(): Promise<Album[]> {
|
||||||
|
const albumData = await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_liked_albums' }).then((response) => response.json() as Promise<InnerTube.Library.AlbumResponse>)
|
||||||
|
|
||||||
|
const { items, continuations } = albumData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
|
||||||
|
let continuation = continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
|
while (continuation) {
|
||||||
|
const continuationData = await this.requestManager
|
||||||
|
.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation })
|
||||||
|
.then((response) => response.json() as Promise<InnerTube.Library.AlbumContinuationResponse>)
|
||||||
|
|
||||||
|
items.push(...continuationData.continuationContents.gridContinuation.items)
|
||||||
|
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
|
||||||
|
return items.map((item) => {
|
||||||
|
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId
|
||||||
|
const name = item.musicTwoRowItemRenderer.title.runs[0].text
|
||||||
|
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
|
let artists: Album['artists'] = []
|
||||||
|
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
|
||||||
|
if (run.text === 'Various Artists') return (artists = 'Various Artists')
|
||||||
|
if (run.navigationEndpoint && artists instanceof Array) artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
|
||||||
|
})
|
||||||
|
|
||||||
|
const releaseYear = item.musicTwoRowItemRenderer.subtitle.runs.at(-1)?.text!
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } satisfies Album
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async artists(): Promise<Artist[]> {
|
||||||
|
const artistsData = await this.requestManager
|
||||||
|
.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_library_corpus_track_artists' })
|
||||||
|
.then((response) => response.json() as Promise<InnerTube.Library.ArtistResponse>)
|
||||||
|
|
||||||
|
const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer
|
||||||
|
let continuation = continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
|
while (continuation) {
|
||||||
|
const continuationData = await this.requestManager
|
||||||
|
.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation })
|
||||||
|
.then((response) => response.json() as Promise<InnerTube.Library.ArtistContinuationResponse>)
|
||||||
|
|
||||||
|
contents.push(...continuationData.continuationContents.musicShelfContinuation.contents)
|
||||||
|
continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
|
||||||
|
return contents.map((item) => {
|
||||||
|
const id = item.musicResponsiveListItemRenderer.navigationEndpoint.browseEndpoint.browseId
|
||||||
|
const name = item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
|
||||||
|
const profilePicture = extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'artist', profilePicture } satisfies Artist
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public async playlists(): Promise<Playlist[]> {
|
||||||
|
const playlistData = await this.requestManager.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_liked_playlists' }).then((response) => response.json() as Promise<InnerTube.Library.PlaylistResponse>)
|
||||||
|
|
||||||
|
const { items, continuations } = playlistData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer
|
||||||
|
let continuation = continuations?.[0].nextContinuationData.continuation
|
||||||
|
|
||||||
|
while (continuation) {
|
||||||
|
const continuationData = await this.requestManager
|
||||||
|
.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation })
|
||||||
|
.then((response) => response.json() as Promise<InnerTube.Library.PlaylistContinuationResponse>)
|
||||||
|
|
||||||
|
items.push(...continuationData.continuationContents.gridContinuation.items)
|
||||||
|
continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlists = items.filter(
|
||||||
|
(item): item is { musicTwoRowItemRenderer: InnerTube.Library.PlaylistMusicTwoRowItemRenderer } =>
|
||||||
|
'browseEndpoint' in item.musicTwoRowItemRenderer.navigationEndpoint &&
|
||||||
|
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLLM' &&
|
||||||
|
item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId !== 'VLSE',
|
||||||
|
)
|
||||||
|
|
||||||
|
const connection = { id: this.connectionId, type: 'youtube-music' } satisfies Album['connection']
|
||||||
|
return playlists.map((item) => {
|
||||||
|
const id = item.musicTwoRowItemRenderer.navigationEndpoint.browseEndpoint.browseId.slice(2)
|
||||||
|
const name = item.musicTwoRowItemRenderer.title.runs[0].text
|
||||||
|
const thumbnailUrl = extractLargestThumbnailUrl(item.musicTwoRowItemRenderer.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails)
|
||||||
|
|
||||||
|
let createdBy: Playlist['createdBy']
|
||||||
|
item.musicTwoRowItemRenderer.subtitle.runs.forEach((run) => {
|
||||||
|
if (run.navigationEndpoint && run.navigationEndpoint.browseEndpoint.browseId !== this.youtubeUserId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies Playlist
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param duration Timestamp in standard ISO8601 format PnDTnHnMnS
|
* @param duration Timestamp in standard ISO8601 format PnDTnHnMnS
|
||||||
* @returns The duration of the timestamp in seconds
|
* @returns The duration of the timestamp in seconds
|
||||||
@@ -812,20 +942,22 @@ function secondsFromISO8601(duration: string): number {
|
|||||||
* It is generally best practice to not directly scrape these video thumbnails directly from youtube and insted get the highest res from the v3 api.
|
* It is generally best practice to not directly scrape these video thumbnails directly from youtube and insted get the highest res from the v3 api.
|
||||||
* However there a few instances in which we want to scrape a thumbail directly from the webapp (e.g. Playlist thumbanils) so it still remains valid.
|
* However there a few instances in which we want to scrape a thumbail directly from the webapp (e.g. Playlist thumbanils) so it still remains valid.
|
||||||
*/
|
*/
|
||||||
function cleanThumbnailUrl(urlString: string): string {
|
function extractLargestThumbnailUrl(thumbnails: Array<{ url: string; width: number; height: number }>): string {
|
||||||
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
|
const bestThumbnailURL = thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url
|
||||||
|
if (!URL.canParse(bestThumbnailURL)) throw new Error('Invalid thumbnail url')
|
||||||
|
|
||||||
switch (new URL(urlString).origin) {
|
switch (new URL(bestThumbnailURL).origin) {
|
||||||
case 'https://lh3.googleusercontent.com':
|
case 'https://lh3.googleusercontent.com':
|
||||||
case 'https://yt3.googleusercontent.com':
|
case 'https://yt3.googleusercontent.com':
|
||||||
case 'https://yt3.ggpht.com':
|
case 'https://yt3.ggpht.com':
|
||||||
return urlString.slice(0, urlString.indexOf('='))
|
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('='))
|
||||||
case 'https://music.youtube.com':
|
case 'https://music.youtube.com':
|
||||||
return urlString
|
return bestThumbnailURL
|
||||||
|
case 'https://www.gstatic.com': // This url will usually contain static images like a placeholder artist profile picture for example
|
||||||
case 'https://i.ytimg.com':
|
case 'https://i.ytimg.com':
|
||||||
return urlString.slice(0, urlString.indexOf('?'))
|
return bestThumbnailURL.slice(0, bestThumbnailURL.indexOf('?'))
|
||||||
default:
|
default:
|
||||||
console.error('Tried to clean invalid url: ' + urlString)
|
console.error('Tried to clean invalid url: ' + bestThumbnailURL)
|
||||||
throw new Error('Invalid thumbnail url origin')
|
throw new Error('Invalid thumbnail url origin')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,69 +8,119 @@ export const newestAlert: Writable<[AlertType, string]> = writable()
|
|||||||
const youtubeMusicBackground: string = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // Default Youtube music background
|
const youtubeMusicBackground: string = 'https://www.gstatic.com/youtube/media/ytm/images/sbg/wsbg@4000x2250.png' // Default Youtube music background
|
||||||
export const backgroundImage: Writable<string> = writable(youtubeMusicBackground)
|
export const backgroundImage: Writable<string> = writable(youtubeMusicBackground)
|
||||||
|
|
||||||
|
function fisherYatesShuffle<T>(items: T[]) {
|
||||||
|
for (let currentIndex = items.length - 1; currentIndex >= 0; currentIndex--) {
|
||||||
|
let randomIndex = Math.floor(Math.random() * (currentIndex + 1))
|
||||||
|
|
||||||
|
;[items[currentIndex], items[randomIndex]] = [items[randomIndex], items[currentIndex]]
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? New idea for how to handle mixing. Keep originalSongs and currentSongs but also add playedSongs. Add the previous song to played songs whenever next() is called.
|
||||||
|
// ? Whenever a song is mixed, set currentSongs = [...playedSongs, currentSongs[currentPosition], ...fisherYatesShuffle(everything else)]. Reorder method would stay the same.
|
||||||
|
// ? IDK it's a thought
|
||||||
|
|
||||||
class Queue {
|
class Queue {
|
||||||
private currentPosition: number // -1 means there is no current position
|
private currentPosition: number // -1 means no song is playing
|
||||||
private songs: Song[]
|
private originalSongs: Song[]
|
||||||
|
private currentSongs: Song[]
|
||||||
|
|
||||||
|
private shuffled: boolean
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.currentPosition = -1
|
this.currentPosition = -1
|
||||||
this.songs = []
|
this.originalSongs = []
|
||||||
|
this.currentSongs = []
|
||||||
|
|
||||||
|
this.shuffled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
get current() {
|
get current() {
|
||||||
if (this.songs.length === 0) return null
|
if (this.currentSongs.length === 0) return null
|
||||||
|
|
||||||
if (this.currentPosition === -1) this.currentPosition = 0
|
if (this.currentPosition === -1) this.currentPosition = 0
|
||||||
return this.songs[this.currentPosition]
|
return this.currentSongs[this.currentPosition]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets the currently playing song to the song provided as long as it is in the current playlist */
|
||||||
set current(newSong: Song | null) {
|
set current(newSong: Song | null) {
|
||||||
if (newSong === null) {
|
if (newSong === null) {
|
||||||
this.currentPosition = -1
|
this.currentPosition = -1
|
||||||
} else {
|
} else {
|
||||||
const queuePosition = this.songs.findIndex((song) => song === newSong)
|
const queuePosition = this.currentSongs.findIndex((song) => song === newSong)
|
||||||
if (queuePosition < 0) {
|
if (queuePosition >= 0) this.currentPosition = queuePosition
|
||||||
this.songs = [newSong]
|
|
||||||
this.currentPosition = 0
|
|
||||||
} else {
|
|
||||||
this.currentPosition = queuePosition
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writableQueue.set(this)
|
writableQueue.set(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
get list() {
|
get list() {
|
||||||
return this.songs
|
return this.currentSongs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isShuffled() {
|
||||||
|
return this.shuffled
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shuffles all songs in the queue after the currently playing song */
|
||||||
|
public shuffle() {
|
||||||
|
const shuffledSongs = fisherYatesShuffle(this.currentSongs.slice(this.currentPosition + 1))
|
||||||
|
this.currentSongs = this.currentSongs.slice(0, this.currentPosition + 1).concat(shuffledSongs)
|
||||||
|
this.shuffled = true
|
||||||
|
writableQueue.set(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restores the queue to its original ordered state, while maintaining whatever song is currently playing */
|
||||||
|
public reorder() {
|
||||||
|
const originalPosition = this.originalSongs.findIndex((song) => song === this.currentSongs[this.currentPosition])
|
||||||
|
this.currentSongs = [...this.originalSongs]
|
||||||
|
this.currentPosition = originalPosition
|
||||||
|
this.shuffled = false
|
||||||
|
writableQueue.set(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the next song */
|
||||||
public next() {
|
public next() {
|
||||||
if (this.songs.length === 0 || this.songs.length <= this.currentPosition + 1) return
|
if (this.currentSongs.length === 0 || this.currentSongs.length <= this.currentPosition + 1) return
|
||||||
|
|
||||||
this.currentPosition += 1
|
this.currentPosition += 1
|
||||||
writableQueue.set(this)
|
writableQueue.set(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plays the previous song */
|
||||||
public previous() {
|
public previous() {
|
||||||
if (this.songs.length === 0 || this.currentPosition <= 0) return
|
if (this.currentSongs.length === 0 || this.currentPosition <= 0) return
|
||||||
|
|
||||||
this.currentPosition -= 1
|
this.currentPosition -= 1
|
||||||
writableQueue.set(this)
|
writableQueue.set(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Add songs to the end of the queue */
|
||||||
public enqueue(...songs: Song[]) {
|
public enqueue(...songs: Song[]) {
|
||||||
this.songs.push(...songs)
|
this.originalSongs.push(...songs)
|
||||||
|
this.currentSongs.push(...songs)
|
||||||
writableQueue.set(this)
|
writableQueue.set(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
public setQueue(...songs: Song[]) {
|
/**
|
||||||
this.songs = songs
|
* @param songs An ordered array of Songs
|
||||||
this.currentPosition = songs.length === 0 ? -1 : 0
|
* @param shuffled Whether or not to shuffle the queue before starting playback. False if not specified
|
||||||
|
*/
|
||||||
|
public setQueue(params: { songs: Song[]; shuffled?: boolean }) {
|
||||||
|
if (params.songs.length === 0) return // Should not set a queue with no songs, use clear()
|
||||||
|
this.originalSongs = params.songs
|
||||||
|
this.currentSongs = params.shuffled ? fisherYatesShuffle(params.songs) : params.songs
|
||||||
|
this.currentPosition = 0
|
||||||
|
this.shuffled = params.shuffled ?? false
|
||||||
writableQueue.set(this)
|
writableQueue.set(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clears all items from the queue */
|
||||||
public clear() {
|
public clear() {
|
||||||
this.currentPosition = -1
|
this.currentPosition = -1
|
||||||
this.songs = []
|
this.originalSongs = []
|
||||||
|
this.currentSongs = []
|
||||||
writableQueue.set(this)
|
writableQueue.set(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
playlistTooltip.style.left = `${x}px`
|
playlistTooltip.style.left = `${x}px`
|
||||||
playlistTooltip.style.top = `${y}px`
|
playlistTooltip.style.top = `${y}px`
|
||||||
}
|
}
|
||||||
|
|
||||||
let expanded = false
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-full overflow-hidden">
|
<div class="h-full overflow-hidden">
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
|
||||||
import type { PageServerLoad } from './$types'
|
import type { PageServerLoad } from './$types'
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, fetch }) => {
|
||||||
const getRecommendations = async (): Promise<(Song | Album | Artist | Playlist)[]> => {
|
const getRecommendations = async () =>
|
||||||
const recommendationResponse = await fetch(`/api/users/${locals.user.id}/recommendations`, {
|
fetch(`/api/users/${locals.user.id}/recommendations`)
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
.then((response) => response.json() as Promise<{ recommendations: (Song | Album | Artist | Playlist)[] }>)
|
||||||
}).then((response) => response.json())
|
.then((data) => data.recommendations)
|
||||||
return recommendationResponse.recommendations
|
|
||||||
}
|
|
||||||
|
|
||||||
return { recommendations: getRecommendations() }
|
return { recommendations: getRecommendations() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
|
||||||
import type { PageServerLoad } from './$types'
|
import type { PageServerLoad } from './$types'
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
const connectionId = url.searchParams.get('connection')
|
const connectionId = url.searchParams.get('connection')
|
||||||
const id = url.searchParams.get('id')
|
const id = url.searchParams.get('id')
|
||||||
|
|
||||||
async function getAlbum(): Promise<Album> {
|
const getAlbum = async () =>
|
||||||
const albumResponse = (await fetch(`/api/connections/${connectionId}/album?id=${id}`, {
|
fetch(`/api/connections/${connectionId}/album?id=${id}`)
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
.then((response) => response.json() as Promise<{ album: Album }>)
|
||||||
}).then((response) => response.json())) as { album: Album }
|
.then((data) => data.album)
|
||||||
return albumResponse.album
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAlbumItems(): Promise<Song[]> {
|
const getAlbumItems = async () =>
|
||||||
const itemsResponse = (await fetch(`/api/connections/${connectionId}/album/${id}/items`, {
|
fetch(`/api/connections/${connectionId}/album/${id}/items`)
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
.then((response) => response.json() as Promise<{ items: Song[] }>)
|
||||||
}).then((response) => response.json())) as { items: Song[] }
|
.then((data) => data.items)
|
||||||
return itemsResponse.items
|
|
||||||
}
|
|
||||||
|
|
||||||
return { albumDetails: Promise.all([getAlbum(), getAlbumItems()]) }
|
return { albumDetails: Promise.all([getAlbum(), getAlbumItems()]) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PageLoad } from './$types'
|
import type { PageServerLoad } from './$types'
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, url }) => {
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
const connectionId = url.searchParams.get('connection')
|
const connectionId = url.searchParams.get('connection')
|
||||||
const id = url.searchParams.get('id')
|
const id = url.searchParams.get('id')
|
||||||
|
|
||||||
22
src/routes/(app)/library/+page.server.ts
Normal file
22
src/routes/(app)/library/+page.server.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { PageServerLoad } from '../$types'
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||||
|
const connectionId = url.searchParams.get('connection')
|
||||||
|
const id = url.searchParams.get('id')
|
||||||
|
|
||||||
|
async function getPlaylist() {
|
||||||
|
const playlistResponse = (await fetch(`/api/connections/${connectionId}/playlist?id=${id}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
}).then((response) => response.json())) as { playlist: Playlist }
|
||||||
|
return playlistResponse.playlist
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPlaylistItems() {
|
||||||
|
const itemsResponse = (await fetch(`/api/connections/${connectionId}/playlist/${id}/items`, {
|
||||||
|
credentials: 'include',
|
||||||
|
}).then((response) => response.json())) as { items: Song[] }
|
||||||
|
return itemsResponse.items
|
||||||
|
}
|
||||||
|
|
||||||
|
return { playlistDetails: Promise.all([getPlaylist(), getPlaylistItems()]) }
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
import type { PageServerLoad } from '../$types'
|
import type { PageServerLoad } from '../$types'
|
||||||
import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
export const load: PageServerLoad = async ({ fetch, url, locals }) => {
|
||||||
const query = url.searchParams.get('query')
|
const query = url.searchParams.get('query')
|
||||||
if (query) {
|
if (query) {
|
||||||
const getSearchResults = async (): Promise<(Song | Album | Artist | Playlist)[]> => {
|
const getSearchResults = async () =>
|
||||||
const searchResults = await fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {
|
fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {})
|
||||||
method: 'GET',
|
.then((response) => response.json() as Promise<{ searchResults: (Song | Album | Artist | Playlist)[] }>)
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
.then((data) => data.searchResults)
|
||||||
}).then((response) => response.json())
|
|
||||||
return searchResults.searchResults
|
|
||||||
}
|
|
||||||
|
|
||||||
return { searchResults: getSearchResults() }
|
return { searchResults: getSearchResults() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fail } from '@sveltejs/kit'
|
import { fail } from '@sveltejs/kit'
|
||||||
import { SECRET_INTERNAL_API_KEY, YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
||||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||||
import type { PageServerLoad, Actions } from './$types'
|
import type { PageServerLoad, Actions } from './$types'
|
||||||
import { DB } from '$lib/server/db'
|
import { DB } from '$lib/server/db'
|
||||||
@@ -7,13 +7,11 @@ import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin'
|
|||||||
import { google } from 'googleapis'
|
import { google } from 'googleapis'
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||||
const getConnectionInfo = async (): Promise<ConnectionInfo[]> => {
|
const getConnectionInfo = async () =>
|
||||||
const connectionInfoResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
|
fetch(`/api/users/${locals.user.id}/connections`)
|
||||||
method: 'GET',
|
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>)
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
.then((data) => data.connections)
|
||||||
}).then((response) => response.json())
|
.catch(() => ({ error: 'Failed to retrieve connections' }))
|
||||||
return connectionInfoResponse.connections
|
|
||||||
}
|
|
||||||
|
|
||||||
return { connections: getConnectionInfo() }
|
return { connections: getConnectionInfo() }
|
||||||
}
|
}
|
||||||
@@ -31,19 +29,16 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, serverUrl: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } })
|
const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, serverUrl: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } })
|
||||||
|
|
||||||
const response = await fetch(`/api/connections?id=${newConnectionId}`, {
|
const newConnection = await fetch(`/api/connections?id=${newConnectionId}`)
|
||||||
method: 'GET',
|
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>)
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
.then((data) => data.connections[0])
|
||||||
}).then((response) => {
|
|
||||||
return response.json()
|
|
||||||
})
|
|
||||||
|
|
||||||
return { newConnection: response.connections[0] }
|
return { newConnection }
|
||||||
},
|
},
|
||||||
youtubeMusicLogin: async ({ request, fetch, locals }) => {
|
youtubeMusicLogin: async ({ request, fetch, locals }) => {
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
const { code } = Object.fromEntries(formData)
|
const { code } = Object.fromEntries(formData)
|
||||||
const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD.
|
const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // ! DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD.
|
||||||
const { tokens } = await client.getToken(code.toString())
|
const { tokens } = await client.getToken(code.toString())
|
||||||
|
|
||||||
const youtube = google.youtube('v3')
|
const youtube = google.youtube('v3')
|
||||||
@@ -57,14 +52,11 @@ export const actions: Actions = {
|
|||||||
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
|
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`/api/connections?id=${newConnectionId}`, {
|
const newConnection = await fetch(`/api/connections?id=${newConnectionId}`)
|
||||||
method: 'GET',
|
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>)
|
||||||
headers: { apikey: SECRET_INTERNAL_API_KEY },
|
.then((data) => data.connections[0])
|
||||||
})
|
|
||||||
|
|
||||||
const responseData = await response.json()
|
return { newConnection }
|
||||||
|
|
||||||
return { newConnection: responseData.connections[0] }
|
|
||||||
},
|
},
|
||||||
deleteConnection: async ({ request }) => {
|
deleteConnection: async ({ request }) => {
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
import ConnectionProfile from './connectionProfile.svelte'
|
import ConnectionProfile from './connectionProfile.svelte'
|
||||||
import { enhance } from '$app/forms'
|
import { enhance } from '$app/forms'
|
||||||
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
|
||||||
import { onMount } from 'svelte'
|
import Loader from '$lib/components/util/loader.svelte'
|
||||||
|
|
||||||
export let data: PageServerData & LayoutData
|
export let data: PageServerData & LayoutData
|
||||||
let connections: ConnectionInfo[]
|
let connections: ConnectionInfo[]
|
||||||
onMount(async () => {
|
let errorMessage: string
|
||||||
connections = await data.connections
|
|
||||||
})
|
data.connections.then((userConnections) => ('error' in userConnections ? (errorMessage = userConnections.error) : (connections = userConnections)))
|
||||||
|
|
||||||
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
|
const authenticateJellyfin: SubmitFunction = ({ formData, cancel }) => {
|
||||||
const { serverUrl, username, password } = Object.fromEntries(formData)
|
const { serverUrl, username, password } = Object.fromEntries(formData)
|
||||||
@@ -138,13 +138,21 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div id="connection-profile-grid" class="grid gap-8">
|
|
||||||
{#if connections}
|
{#if connections}
|
||||||
|
<div id="connection-profile-grid" class="grid gap-8">
|
||||||
{#each connections as connectionInfo}
|
{#each connections as connectionInfo}
|
||||||
<ConnectionProfile {connectionInfo} submitFunction={profileActions} />
|
<ConnectionProfile {connectionInfo} submitFunction={profileActions} />
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
{:else if errorMessage}
|
||||||
|
<div class="grid h-40 place-items-center">
|
||||||
|
<span class="text-4xl">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="relative h-40">
|
||||||
|
<Loader size={5} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if newConnectionModal !== null}
|
{#if newConnectionModal !== null}
|
||||||
<svelte:component this={newConnectionModal} submitFunction={authenticateJellyfin} on:close={() => (newConnectionModal = null)} />
|
<svelte:component this={newConnectionModal} submitFunction={authenticateJellyfin} on:close={() => (newConnectionModal = null)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
|||||||
const limit = Number.isInteger(numberLimit) && numberLimit > 0 ? numberLimit : undefined
|
const limit = Number.isInteger(numberLimit) && numberLimit > 0 ? numberLimit : undefined
|
||||||
|
|
||||||
const response = await connection
|
const response = await connection
|
||||||
.getPlaylistItems(playlistId!, startIndex, limit)
|
.getPlaylistItems(playlistId!, { startIndex, limit })
|
||||||
.then((items) => Response.json({ items }))
|
.then((items) => Response.json({ items }))
|
||||||
.catch((error: TypeError | Error) => {
|
.catch((error: TypeError | Error) => {
|
||||||
if (error instanceof TypeError) return new Response('Bad Request', { status: 400 })
|
if (error instanceof TypeError) return new Response('Bad Request', { status: 400 })
|
||||||
|
|||||||
13
src/routes/api/users/[userId]/library/albums/+server.ts
Normal file
13
src/routes/api/users/[userId]/library/albums/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
|
import { Connections } from '$lib/server/connections'
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const userId = params.userId!
|
||||||
|
|
||||||
|
const userConnections = Connections.getUserConnections(userId)
|
||||||
|
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||||
|
|
||||||
|
const items = (await Promise.all(userConnections.map((connection) => connection.library.albums()))).flat()
|
||||||
|
|
||||||
|
return Response.json({ items })
|
||||||
|
}
|
||||||
13
src/routes/api/users/[userId]/library/artists/+server.ts
Normal file
13
src/routes/api/users/[userId]/library/artists/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
|
import { Connections } from '$lib/server/connections'
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const userId = params.userId!
|
||||||
|
|
||||||
|
const userConnections = Connections.getUserConnections(userId)
|
||||||
|
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||||
|
|
||||||
|
const items = (await Promise.all(userConnections.map((connection) => connection.library.artists()))).flat()
|
||||||
|
|
||||||
|
return Response.json({ items })
|
||||||
|
}
|
||||||
13
src/routes/api/users/[userId]/library/playlists/+server.ts
Normal file
13
src/routes/api/users/[userId]/library/playlists/+server.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit'
|
||||||
|
import { Connections } from '$lib/server/connections'
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
const userId = params.userId!
|
||||||
|
|
||||||
|
const userConnections = Connections.getUserConnections(userId)
|
||||||
|
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||||
|
|
||||||
|
const items = (await Promise.all(userConnections.map((connection) => connection.library.playlists()))).flat()
|
||||||
|
|
||||||
|
return Response.json({ items })
|
||||||
|
}
|
||||||
BIN
static/WIP-2024-06-04.png
Normal file
BIN
static/WIP-2024-06-04.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Reference in New Issue
Block a user