first commit
This commit is contained in:
commit
010598a8be
15 changed files with 1128 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
WEBHOOK_URL=https://discord.com/api/webhooks/no/no
|
||||
DISCORD_TOKEN="no.no"
|
||||
LASTFM_API_KEY=no
|
||||
|
||||
NAVIDROME_URL=https://no.no.no
|
||||
NAVIDROME_USER="no"
|
||||
NAVIDROME_PASS="no"
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.env
|
||||
deezer_cache
|
||||
orpheus_links.txt
|
||||
discord_send.txt
|
||||
|
||||
node_modules
|
||||
31
README.md
Normal file
31
README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# music-library-tools
|
||||
|
||||
Consists of many tools used in my music library.
|
||||
|
||||
## Tool listing
|
||||
|
||||
WARNING! Most of these scripts have extremely hardcoded file directory paths. Fix them yourself (or wait untill I make them work differently) if you'd like to use these scripts elsewhere.
|
||||
|
||||
### Javascript
|
||||
- `bun run pillow https://pillows.su/....`
|
||||
Downloading any audio from a pillow.su link, with correct filename and et cetera.
|
||||
- `bun run deezify`
|
||||
Configuration for this is inside of the file, but this allows you to pull data from deezer and then look through it, and then send it off to OrpheusDL.
|
||||
- `bun run now-playing`
|
||||
Show the now playing of all of your users in Navidrome through a discord webhook.
|
||||
- `bun run request-bot`
|
||||
Discord bot that allows you to make requests to a administration team with metadata required to properly figure out if a request is trustworthy and required.
|
||||
|
||||
### Python
|
||||
- `python3 misc/fix_singles.py`
|
||||
Fixes singles that come from OrpheusDL. They are actually just saved as .lrc + .flac/.mp3/.m4a, and aren't in their own directory. This basically moves, let's say "A Milli.mp3" and "A Milli.lrc" to "Lil Wayne (singles)/A Milli.mp3" and "Lil Wayne (singles)/A Milli.lrc".
|
||||
- `python3 misc/fix_utf8_filenames_only.py`
|
||||
Some exports, as sometimes seen in Soulseek files, they come with extremely broken UTF8 filenames. Navidrome does not support these, so I have to fix them and take out all utf8 filenames.
|
||||
- `python3 misc/fix_utf8_tags.py`
|
||||
Same shit as with the above script, but for ID3v2 tags.
|
||||
- `python3 misc/lrcput+txt.py`
|
||||
The [lrcput script](https://github.com/JustOptimize/lrcput/blob/main/lrcput.py), but I added support for .txt files and not only .lrc's.
|
||||
|
||||
### Bash
|
||||
- `./misc/fix_dates_deezer_dl.sh`
|
||||
deezer-downloader gives albums with inconsistent dates. This breaks Navidrome, so we have to normalize the dates.
|
||||
75
bun.lock
Normal file
75
bun.lock
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "music-library-tools",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.25.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="],
|
||||
|
||||
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
||||
|
||||
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
|
||||
|
||||
"@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
|
||||
|
||||
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
|
||||
|
||||
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
||||
|
||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||
|
||||
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
||||
|
||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
|
||||
|
||||
"discord-api-types": ["discord-api-types@0.38.37", "", {}, "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w=="],
|
||||
|
||||
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||
|
||||
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
|
||||
|
||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
}
|
||||
}
|
||||
35
misc/fix_dates_deezer_dl.sh
Normal file
35
misc/fix_dates_deezer_dl.sh
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/bin/bash
|
||||
|
||||
BASE=/tmp/deezer-downloader/albums/
|
||||
cd "$BASE" || exit 1
|
||||
|
||||
shopt -s nullglob
|
||||
|
||||
for dir in */ ; do
|
||||
DATES=$(~/bin/exiftool/exiftool -Date "$dir"/*.flac "$dir"/*.mp3 2>/dev/null \
|
||||
| awk -F': ' '{if($2!="") print $2}' | sort -u)
|
||||
|
||||
COUNT=$(echo "$DATES" | wc -l)
|
||||
|
||||
if [ "$COUNT" -le 1 ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
NEWDATE=$(echo "$DATES" | sort -nr | head -n1)
|
||||
|
||||
echo "🔧 $dir → Fixing inconsistent dates to newest: $NEWDATE"
|
||||
echo "$DATES" | sed 's/^/ - /'
|
||||
|
||||
for f in "$dir"/*.flac; do
|
||||
[ -f "$f" ] || continue
|
||||
metaflac --remove-tag=DATE "$f"
|
||||
metaflac --set-tag=DATE="$NEWDATE" "$f"
|
||||
done
|
||||
|
||||
for f in "$dir"/*.mp3; do
|
||||
[ -f "$f" ] || continue
|
||||
id3v2 -T "$NEWDATE" "$f" >/dev/null 2>&1
|
||||
done
|
||||
|
||||
echo
|
||||
done
|
||||
63
misc/fix_singles.py
Normal file
63
misc/fix_singles.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import os
|
||||
import shutil
|
||||
from mutagen import File
|
||||
|
||||
# Path where your music files are
|
||||
MUSIC_DIR = "/home/fucksophie/OrpheusDL/downloads"
|
||||
|
||||
# First, collect all .lrc files
|
||||
lrc_files = {}
|
||||
for filename in os.listdir(MUSIC_DIR):
|
||||
if filename.lower().endswith(".lrc"):
|
||||
key = os.path.splitext(filename)[0] # filename without extension
|
||||
lrc_files[key] = os.path.join(MUSIC_DIR, filename)
|
||||
|
||||
# Loop through all items in the directory
|
||||
for filename in os.listdir(MUSIC_DIR):
|
||||
filepath = os.path.join(MUSIC_DIR, filename)
|
||||
|
||||
# Only process files, ignore directories
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
|
||||
# Only process .mp3 and .flac files
|
||||
if not (filename.lower().endswith(".mp3") or filename.lower().endswith(".flac")):
|
||||
continue
|
||||
|
||||
# Load file with mutagen
|
||||
audio = File(filepath, easy=True)
|
||||
if not audio:
|
||||
print(f"Skipping {filename}, can't read metadata")
|
||||
continue
|
||||
|
||||
# Try to get the album artist tag; fallback to artist if missing
|
||||
artist = None
|
||||
if "albumartist" in audio:
|
||||
artist = audio["albumartist"][0]
|
||||
elif "artist" in audio:
|
||||
artist = audio["artist"][0]
|
||||
else:
|
||||
print(f"No artist tag found for {filename}, skipping")
|
||||
continue
|
||||
|
||||
# Clean up artist name for folder creation
|
||||
safe_artist = artist.replace("/", "_").replace("\\", "_")
|
||||
folder_name = f"{safe_artist} (singles)"
|
||||
target_folder = os.path.join(MUSIC_DIR, folder_name)
|
||||
|
||||
# Create folder if it doesn't exist
|
||||
os.makedirs(target_folder, exist_ok=True)
|
||||
|
||||
# Move file into folder
|
||||
target_path = os.path.join(target_folder, filename)
|
||||
print(f"Moving '{filename}' to '{target_folder}'")
|
||||
shutil.move(filepath, target_path)
|
||||
|
||||
# Also move corresponding .lrc file if it exists
|
||||
base_name = os.path.splitext(filename)[0]
|
||||
if base_name in lrc_files:
|
||||
lrc_target = os.path.join(target_folder, os.path.basename(lrc_files[base_name]))
|
||||
print(f"Moving lyrics '{lrc_files[base_name]}' to '{target_folder}'")
|
||||
shutil.move(lrc_files[base_name], lrc_target)
|
||||
|
||||
print("Done!")
|
||||
27
misc/fix_utf8_filenames_only.py
Normal file
27
misc/fix_utf8_filenames_only.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import os
|
||||
import re
|
||||
|
||||
ROOT = "/home/fucksophie/media/Music/axxturel loosies"
|
||||
SUPPORTED_EXTS = {".mp3", ".m4a"} # add other formats as needed
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
# Keep ASCII printable characters; replace anything else with "_"
|
||||
return re.sub(r"[^\x20-\x7E]", "_", filename)
|
||||
|
||||
def safe_print(s: str):
|
||||
# Encode with backslashreplace to avoid crashing on surrogates
|
||||
print(s.encode("utf-8", errors="backslashreplace").decode("utf-8"))
|
||||
|
||||
for root, _, files in os.walk(ROOT):
|
||||
for f in files:
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
if ext not in SUPPORTED_EXTS:
|
||||
continue
|
||||
|
||||
old_path = os.path.join(root, f)
|
||||
new_name = sanitize_filename(f)
|
||||
new_path = os.path.join(root, new_name)
|
||||
if old_path != new_path:
|
||||
safe_print(f"[DRY RUN] Would rename: {old_path} → {new_path}")
|
||||
# To apply changes, uncomment the next line:
|
||||
os.rename(old_path, new_path)
|
||||
75
misc/fix_utf8_tags.py
Normal file
75
misc/fix_utf8_tags.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import os
|
||||
from typing import Optional
|
||||
from mutagen.id3 import ID3, ID3NoHeaderError, TALB, TIT2
|
||||
|
||||
MUSIC_ROOT = "/home/fucksophie/media/Music"
|
||||
SUPPORTED_EXTS = {".mp3"}
|
||||
DRY_RUN = False
|
||||
|
||||
def deduce_album_from_dir(dirname: str) -> Optional[str]:
|
||||
# "[Facy] - Night school HOSTED BY BRAILLED"
|
||||
if " - " in dirname:
|
||||
return dirname.split(" - ", 1)[1].strip()
|
||||
return None
|
||||
|
||||
def is_singles_dir(dirname: str) -> bool:
|
||||
return dirname.lower().endswith("(singles)")
|
||||
|
||||
def title_from_filename(filename: str) -> str:
|
||||
return os.path.splitext(filename)[0].strip()
|
||||
|
||||
for root, _, files in os.walk(MUSIC_ROOT):
|
||||
dirname = os.path.basename(root)
|
||||
|
||||
album_name = deduce_album_from_dir(dirname)
|
||||
singles = is_singles_dir(dirname)
|
||||
|
||||
for file in files:
|
||||
if os.path.splitext(file)[1].lower() not in SUPPORTED_EXTS:
|
||||
continue
|
||||
|
||||
path = os.path.join(root, file)
|
||||
|
||||
try:
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
tags = ID3()
|
||||
|
||||
# -------- Albums --------
|
||||
if album_name:
|
||||
existing_album = tags.get("TALB")
|
||||
if not (existing_album and existing_album.text and existing_album.text[0].strip()):
|
||||
print(f"[DRY RUN][ALBUM] {path}")
|
||||
print(f" → set album = '{album_name}'")
|
||||
|
||||
if not DRY_RUN:
|
||||
tags.add(TALB(encoding=3, text=album_name))
|
||||
tags.save(path)
|
||||
|
||||
# -------- Singles --------
|
||||
if singles:
|
||||
title = title_from_filename(file)
|
||||
|
||||
existing_title = tags.get("TIT2")
|
||||
existing_album = tags.get("TALB")
|
||||
|
||||
needs_title = not (existing_title and existing_title.text and existing_title.text[0].strip())
|
||||
needs_album = not (existing_album and existing_album.text and existing_album.text[0].strip())
|
||||
|
||||
if needs_title or needs_album:
|
||||
print(f"[DRY RUN][SINGLE] {path}")
|
||||
if needs_title:
|
||||
print(f" → set title = '{title}'")
|
||||
if needs_album:
|
||||
print(" → set album = 'singles'")
|
||||
|
||||
if not DRY_RUN:
|
||||
if needs_title:
|
||||
tags.add(TIT2(encoding=3, text=title))
|
||||
if needs_album:
|
||||
tags.add(TALB(encoding=3, text="singles"))
|
||||
tags.save(path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {path}: {e}")
|
||||
131
misc/lrcput+txt.py
Normal file
131
misc/lrcput+txt.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import os
|
||||
import shutil
|
||||
import argparse
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.mp4 import MP4
|
||||
import eyed3
|
||||
from tqdm import tqdm
|
||||
|
||||
def has_embedded_lyrics(audio):
|
||||
if isinstance(audio, FLAC):
|
||||
return 'LYRICS' in audio
|
||||
elif isinstance(audio, MP4):
|
||||
return '\xa9lyr' in audio.tags
|
||||
elif isinstance(audio, eyed3.core.AudioFile):
|
||||
return audio.tag.lyrics is not None
|
||||
return False
|
||||
|
||||
def embed_lrc(directory, skip_existing, reduce_lrc, recursive):
|
||||
embedded_lyrics_files = 0
|
||||
failed_files = []
|
||||
|
||||
audio_files = []
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if file.endswith('.flac') or file.endswith('.mp3') or file.endswith('.m4a'):
|
||||
audio_files.append(os.path.join(root, file))
|
||||
|
||||
with tqdm(total=len(audio_files), desc='Embedding LRC files', unit='file') as pbar:
|
||||
for audio_path in audio_files:
|
||||
file = os.path.basename(audio_path)
|
||||
base = os.path.splitext(file)[0]
|
||||
|
||||
lrc_path = os.path.join(os.path.dirname(audio_path), base + '.lrc')
|
||||
txt_path = os.path.join(os.path.dirname(audio_path), base + '.txt')
|
||||
|
||||
lyrics_path = None
|
||||
if os.path.exists(lrc_path):
|
||||
lyrics_path = lrc_path
|
||||
elif os.path.exists(txt_path):
|
||||
lyrics_path = txt_path
|
||||
|
||||
if not lyrics_path:
|
||||
pbar.set_postfix({"status": "no lrc/txt"})
|
||||
pbar.update(1)
|
||||
continue
|
||||
|
||||
if skip_existing:
|
||||
audio = None
|
||||
if file.endswith('.flac'):
|
||||
audio = FLAC(audio_path)
|
||||
elif file.endswith('.mp3'):
|
||||
audio = eyed3.load(audio_path)
|
||||
elif file.endswith('.m4a'):
|
||||
audio = MP4(audio_path)
|
||||
if has_embedded_lyrics(audio):
|
||||
pbar.set_postfix({"status": "skipped"})
|
||||
pbar.update(1)
|
||||
continue
|
||||
|
||||
try:
|
||||
lyrics = open(lyrics_path, 'r', encoding='utf-8').read()
|
||||
|
||||
if file.endswith('.flac'):
|
||||
audio = FLAC(audio_path)
|
||||
audio['LYRICS'] = lyrics
|
||||
audio.save()
|
||||
elif file.endswith('.mp3'):
|
||||
audio = eyed3.load(audio_path)
|
||||
tag = audio.tag
|
||||
tag.lyrics.set(lyrics)
|
||||
tag.save(version=eyed3.id3.ID3_V2_3)
|
||||
elif file.endswith('.m4a'):
|
||||
audio = MP4(audio_path)
|
||||
audio.tags['\xa9lyr'] = lyrics
|
||||
audio.save()
|
||||
|
||||
embedded_lyrics_files += 1
|
||||
pbar.set_postfix({"status": f"embedded: {file}"})
|
||||
pbar.update(1)
|
||||
pbar.refresh()
|
||||
|
||||
if reduce_lrc:
|
||||
os.remove(lyrics_path)
|
||||
pbar.set_postfix({"status": f"embedded, reduced: {file}"})
|
||||
pbar.refresh()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error embedding lyrics for {file}: {str(e)}")
|
||||
pbar.set_postfix({"status": f"error: {file}"})
|
||||
pbar.update(1)
|
||||
pbar.refresh()
|
||||
failed_files.append(file)
|
||||
if os.path.exists(lyrics_path):
|
||||
shutil.move(lyrics_path, lyrics_path + ".failed")
|
||||
continue
|
||||
|
||||
return len(audio_files), embedded_lyrics_files, failed_files
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description='Embed LRC files into audio files (FLAC, MP3, and M4A) and optionally reduce LRC files.')
|
||||
parser.add_argument('-d', '--directory', required=True, help='Directory containing audio and LRC files')
|
||||
parser.add_argument('-s', '--skip', action='store_true', help='Skip files that already have embedded lyrics')
|
||||
parser.add_argument('-r', '--reduce', action='store_true', help='Reduce (delete) LRC files after embedding')
|
||||
parser.add_argument('-R', '--recursive', action='store_true', help='Recursively process subdirectories')
|
||||
args = parser.parse_args()
|
||||
|
||||
banner = """
|
||||
██╗ ██████╗ ██████╗██████╗ ██╗ ██╗████████╗
|
||||
██║ ██╔══██╗██╔════╝██╔══██╗██║ ██║╚══██╔══╝
|
||||
██║ ██████╔╝██║ ██████╔╝██║ ██║ ██║
|
||||
██║ ██╔══██╗██║ ██╔═══╝ ██║ ██║ ██║
|
||||
███████╗██║ ██║╚██████╗██║ ╚██████╔╝ ██║
|
||||
╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═════╝ ╚═╝
|
||||
Scripted by TheRedSpy15, .txt support by Sophie"""
|
||||
print(banner)
|
||||
|
||||
directory_path = args.directory
|
||||
skip_existing = args.skip
|
||||
reduce_lrc = args.reduce
|
||||
recursive = args.recursive
|
||||
total, embedded, failed = embed_lrc(directory_path, skip_existing, reduce_lrc, recursive)
|
||||
percentage = (embedded / total) * 100 if total > 0 else 0
|
||||
|
||||
print(f"Total audio files: {total}")
|
||||
print(f"Embedded lyrics in {embedded} audio files.")
|
||||
print(f"Percentage of audio files with embedded lyrics: {percentage:.2f}%")
|
||||
|
||||
if failed:
|
||||
print("\nFailed to embed LRC for the following files:")
|
||||
for file in failed:
|
||||
print(file)
|
||||
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "music-library-tools",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"scripts": {
|
||||
"deezify": "bun run src/deezify.js",
|
||||
"now-playing": "bun run src/now-playing.js",
|
||||
"request-bot": "bun run src/request-bot.js",
|
||||
"pillow": "bun run src/pillow.js"
|
||||
|
||||
},
|
||||
"engineStrict": true,
|
||||
"engines": {
|
||||
"bun": "*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14.25.1"
|
||||
}
|
||||
}
|
||||
132
src/deezify.js
Normal file
132
src/deezify.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto"
|
||||
|
||||
const CACHE_DIR = "./deezer_cache";
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
|
||||
const artists = [
|
||||
|
||||
];
|
||||
|
||||
const BAD_KEYWORDS = [
|
||||
" remix",
|
||||
"remix ",
|
||||
"remastered",
|
||||
"extended mix",
|
||||
"extended version",
|
||||
" edit",
|
||||
"live",
|
||||
"acoustic",
|
||||
"instrumental",
|
||||
"anniversary",
|
||||
"(remixes)",
|
||||
"(remix)",
|
||||
];
|
||||
|
||||
function normalize(str) {
|
||||
return str.toLowerCase().trim();
|
||||
}
|
||||
|
||||
function hasBadKeyword(title) {
|
||||
const t = normalize(title);
|
||||
return BAD_KEYWORDS.some(k => t.includes(k));
|
||||
}
|
||||
function cachePath(url) {
|
||||
const hash = crypto.createHash("sha1").update(url).digest("hex");
|
||||
return path.join(CACHE_DIR, `${hash}.json`);
|
||||
}
|
||||
|
||||
async function cachedFetch(url) {
|
||||
const file = cachePath(url);
|
||||
|
||||
if (fs.existsSync(file)) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const json = await res.json();
|
||||
|
||||
fs.writeFileSync(file, JSON.stringify(json, null, 2));
|
||||
return json;
|
||||
}
|
||||
|
||||
|
||||
async function getArtistId(name) {
|
||||
const q = encodeURIComponent(name);
|
||||
const data = await cachedFetch(
|
||||
`https://api.deezer.com/search/artist?q=${q}`
|
||||
);
|
||||
return data?.data?.[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function getAllAlbums(artistId) {
|
||||
let url = `https://api.deezer.com/artist/${artistId}/albums?limit=200`;
|
||||
let out = [];
|
||||
|
||||
while (url) {
|
||||
const data = await cachedFetch(url);
|
||||
out.push(...(data.data ?? []));
|
||||
url = data.next ?? null;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function selectBestAlbumVersions(albums) {
|
||||
const byTitle = new Map();
|
||||
|
||||
for (const a of albums) {
|
||||
if (hasBadKeyword(a.title)) continue;
|
||||
|
||||
const key = normalize(a.title.replace(/\s*\(.*?\)\s*/g, ""));
|
||||
|
||||
if (!byTitle.has(key)) {
|
||||
byTitle.set(key, a);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = byTitle.get(key);
|
||||
|
||||
// Prefer explicit
|
||||
if (!existing.explicit_lyrics && a.explicit_lyrics) {
|
||||
byTitle.set(key, a);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byTitle.values()];
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const sex = [];
|
||||
const sex_squared = []
|
||||
|
||||
for (const artist of artists) {
|
||||
console.log(`\n=== ${artist} ===`);
|
||||
|
||||
const artistId = await getArtistId(artist);
|
||||
if (!artistId) {
|
||||
console.log("Artist not found");
|
||||
continue;
|
||||
}
|
||||
|
||||
const albums = await getAllAlbums(artistId);
|
||||
const cleanAlbums = selectBestAlbumVersions(albums);
|
||||
|
||||
for (const a of cleanAlbums) {
|
||||
console.log(
|
||||
`${a.title} | ${a.explicit_lyrics ? "EXPLICIT" : "CLEAN"} | https://www.deezer.com/en/album/${a.id}`
|
||||
);
|
||||
sex_squared.push(`+ ${artist} - ${a.title} ${a.explicit_lyrics ? "[E]" : ""}`)
|
||||
sex.push(`https://www.deezer.com/en/album/${a.id}`)
|
||||
}
|
||||
|
||||
}
|
||||
fs.writeFileSync("orpheus_links.txt", sex.join(" "))
|
||||
fs.writeFileSync("discord_send.txt", sex_squared.join(" \n").match(/.{1,2000}$/gms).map(z => z.trim()).join("\n\n"+'-'.repeat(15)+"\n\n"))
|
||||
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
112
src/now-playing.js
Normal file
112
src/now-playing.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const salt = crypto.randomBytes(6).toString("hex");
|
||||
const token = crypto
|
||||
.createHash("md5")
|
||||
.update(process.env.NAVIDROME_PASS + salt)
|
||||
.digest("hex");
|
||||
|
||||
|
||||
const NOW_PLAYING_URL = `${process.env.NAVIDROME_URL}/rest/getNowPlaying?u=${process.env.NAVIDROME_USER}&t=${token}&s=${salt}&f=json&v=0.0.1&c=now-playing`;
|
||||
const COVER_FETCH_URL = (id) =>
|
||||
`${process.env.NAVIDROME_URL}/rest/getCoverArt?u=${process.env.NAVIDROME_USER}&t=${token}&s=${salt}&f=json&v=0.0.1&c=now-playing&id=${id}&size=80`;
|
||||
|
||||
const WEBHOOK_URL = process.env.WEBHOOK_URL;
|
||||
const REFRESH_MS = 15_000;
|
||||
const STATE_FILE = "./state.json";
|
||||
|
||||
let messageId = null;
|
||||
|
||||
if (existsSync(STATE_FILE)) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
||||
messageId = state.messageId ?? null;
|
||||
} catch {
|
||||
// will be made later
|
||||
}
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
writeFileSync(STATE_FILE, JSON.stringify({ messageId }, null, 2));
|
||||
}
|
||||
|
||||
function colorFromString(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash) % 0xffffff;
|
||||
}
|
||||
|
||||
async function getNowPlaying() {
|
||||
const res = await fetch(NOW_PLAYING_URL);
|
||||
if (!res.ok) throw new Error("Failed to fetch now playing");
|
||||
const json = await res.json();
|
||||
return json["subsonic-response"].nowPlaying?.entry ?? [];
|
||||
}
|
||||
|
||||
async function buildPayload(entries) {
|
||||
if (entries.length === 0) {
|
||||
return { embeds: [{ title: "Nothing playing", description: "No active listeners", color: 0x808080 }], files: [] };
|
||||
}
|
||||
|
||||
const embeds = [];
|
||||
const files = [];
|
||||
|
||||
for (const e of entries) {
|
||||
const filename = `cover_${e.id}.jpg`;
|
||||
const imgRes = await fetch(COVER_FETCH_URL(e.coverArt));
|
||||
if (imgRes.ok) {
|
||||
const buf = await imgRes.arrayBuffer();
|
||||
files.push({ name: filename, data: new Blob([buf]) });
|
||||
}
|
||||
|
||||
embeds.push({
|
||||
title: e.title,
|
||||
description: `**${e.artist}** — *${e.album}*`,
|
||||
color: colorFromString(e.username),
|
||||
thumbnail: { url: `attachment://${filename}` },
|
||||
fields: [
|
||||
{ name: "User", value: e.username, inline: true },
|
||||
{ name: "Player", value: e.playerName, inline: true },
|
||||
{ name: "Duration", value: `${Math.floor(e.duration / 60)}:${String(e.duration % 60).padStart(2, "0")}`, inline: false },
|
||||
],
|
||||
footer: { text: "Navidrome Now Playing" },
|
||||
timestamp: new Date(e.played).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return { embeds, files };
|
||||
}
|
||||
|
||||
async function sendOrResend(payload) {
|
||||
// delete previous message if exists
|
||||
if (messageId) {
|
||||
await fetch(`${WEBHOOK_URL}/messages/${messageId}`, { method: "DELETE" }).catch(() => {});
|
||||
messageId = null;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
payload.files.forEach((f, i) => form.append(`files[${i}]`, f.data, f.name));
|
||||
form.append("payload_json", JSON.stringify({ embeds: payload.embeds }));
|
||||
|
||||
const res = await fetch(`${WEBHOOK_URL}?wait=true`, { method: "POST", body: form });
|
||||
if (!res.ok) throw new Error("Webhook send failed");
|
||||
const data = await res.json();
|
||||
messageId = data.id;
|
||||
saveState();
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const entries = await getNowPlaying();
|
||||
const payload = await buildPayload(entries);
|
||||
await sendOrResend(payload);
|
||||
} catch (err) {
|
||||
console.error("Update failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await tick();
|
||||
setInterval(tick, REFRESH_MS);
|
||||
139
src/pillow.js
Normal file
139
src/pillow.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import https from "https";
|
||||
import { createWriteStream } from "fs";
|
||||
|
||||
// ======== COLOR UTILS ==========
|
||||
const C = {
|
||||
info: msg => console.log("\x1b[36m[i]\x1b[0m " + msg),
|
||||
ok: msg => console.log("\x1b[32m[✓]\x1b[0m " + msg),
|
||||
warn: msg => console.log("\x1b[33m[!]\x1b[0m " + msg),
|
||||
err: msg => console.log("\x1b[31m[✗]\x1b[0m " + msg),
|
||||
};
|
||||
|
||||
// ======== DOWNLOAD WITH PROGRESS ==========
|
||||
function download(url, filename, referer = url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let file = null; // will open later
|
||||
|
||||
function doReq(link, ref) {
|
||||
C.info(`Requesting: ${link}`);
|
||||
|
||||
https.get(link, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Referer": ref
|
||||
}
|
||||
}, res => {
|
||||
|
||||
// --- Redirect handler ---
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
|
||||
const loc = res.headers.location;
|
||||
if (!loc) return reject(new Error("Redirect without Location header"));
|
||||
const nextURL = loc.startsWith("http") ? loc : new URL(loc, link).href;
|
||||
C.warn(`↪ Redirect (${res.statusCode}): ${link} → ${nextURL}`);
|
||||
return doReq(nextURL, link);
|
||||
}
|
||||
|
||||
// --- Error handler ---
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`HTTP ${res.statusCode}`));
|
||||
}
|
||||
|
||||
// ======= OPEN FILE NOW (final target reached) =======
|
||||
if (!file) file = createWriteStream(filename);
|
||||
|
||||
// ======= Progress =======
|
||||
const total = parseInt(res.headers["content-length"] || "0", 10);
|
||||
let downloaded = 0;
|
||||
const start = Date.now();
|
||||
|
||||
res.on("data", chunk => {
|
||||
downloaded += chunk.length;
|
||||
|
||||
if (total) {
|
||||
const percent = ((downloaded / total) * 100).toFixed(1);
|
||||
const speed = (downloaded / 1024 / ((Date.now() - start) / 1000)).toFixed(1);
|
||||
const barSize = 30;
|
||||
const filled = Math.round(percent / 100 * barSize);
|
||||
const bar = "[" + "#".repeat(filled) + "-".repeat(barSize - filled) + "]";
|
||||
process.stdout.write(`\r${bar} ${percent}% ${speed}KB/s`);
|
||||
} else {
|
||||
process.stdout.write(`\rDownloaded ${Math.round(downloaded / 1024)}KB`);
|
||||
}
|
||||
});
|
||||
|
||||
res.pipe(file);
|
||||
|
||||
file.on("finish", () => {
|
||||
process.stdout.write("\n");
|
||||
C.ok("Download complete!");
|
||||
resolve();
|
||||
});
|
||||
|
||||
}).on("error", err => reject(err));
|
||||
}
|
||||
|
||||
doReq(url, referer);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ======== MAIN FUNCTION ==========
|
||||
(async () => {
|
||||
const target = process.argv[2];
|
||||
|
||||
if (!target) {
|
||||
C.err("Usage: node pillows-dl.js https://pillows.su/f/ID");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
C.info(`Fetching webpage: ${target}`);
|
||||
|
||||
let html;
|
||||
try {
|
||||
html = await fetch(target, { headers: { "User-Agent": "Mozilla/5.0" } }).then(r => r.text());
|
||||
} catch (e) {
|
||||
C.err("Failed to fetch the page");
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const match = html.match(/data:\s*\[(.*)\],/);
|
||||
if (!match) {
|
||||
C.err("❌ Failed to extract data[] from webpage!");
|
||||
process.exit(1);
|
||||
}
|
||||
C.ok("Found data[] block");
|
||||
|
||||
// Safer eval (only our captured data)
|
||||
let arr;
|
||||
try {
|
||||
arr = eval("["+match[1]+"]");
|
||||
} catch (e) {
|
||||
C.err("❌ Failed to parse data[] as JavaScript");
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const item = arr.find(x => x?.type === "data" && x?.data?.filename);
|
||||
if (!item) {
|
||||
C.err("❌ Could not find downloadable file info in data[]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = item.data;
|
||||
C.ok(`File: ${data.filename}`);
|
||||
C.info(`ID: ${data.id}`);
|
||||
C.info(`Views: ${data.views}`);
|
||||
C.info(`Bitrate: ${data.bitrate || "??"} kbps`);
|
||||
|
||||
const url = `https://api.pillows.su/api/download/${data.id}.mp3`;
|
||||
|
||||
try {
|
||||
await download(url, data.filename);
|
||||
C.ok("Done!");
|
||||
} catch (e) {
|
||||
C.err("❌ Download failed");
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
242
src/request-bot.js
Normal file
242
src/request-bot.js
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import {
|
||||
Client,
|
||||
GatewayIntentBits,
|
||||
SlashCommandBuilder,
|
||||
REST,
|
||||
Routes,
|
||||
} from "discord.js";
|
||||
import crypto from "crypto";
|
||||
|
||||
const GUILD_ID = "1449756600733798501";
|
||||
const REQUEST_CHANNEL_ID = "1449767378882920568";
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds],
|
||||
});
|
||||
|
||||
const command = new SlashCommandBuilder()
|
||||
.setName("request")
|
||||
.setDescription("Request a new artist or submit a SendGB link")
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName("artist")
|
||||
.setDescription("Artist name (Last.fm)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName("url")
|
||||
.setDescription("SendGB URL (https://sendgb.com/...)")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName("description")
|
||||
.setDescription("Description for the SendGB link")
|
||||
.setRequired(false)
|
||||
);
|
||||
|
||||
function isValidSendGbUrl(url) {
|
||||
return /^https:\/\/sendgb\.com\/[A-Za-z0-9]+$/.test(url);
|
||||
}
|
||||
|
||||
|
||||
const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_TOKEN);
|
||||
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(
|
||||
(await rest.get(Routes.oauth2CurrentApplication())).id,
|
||||
GUILD_ID
|
||||
),
|
||||
{ body: [command.toJSON()] }
|
||||
);
|
||||
|
||||
|
||||
async function getLastFmArtist(artist) {
|
||||
const url =
|
||||
"https://ws.audioscrobbler.com/2.0/?" +
|
||||
new URLSearchParams({
|
||||
method: "artist.getinfo",
|
||||
artist,
|
||||
api_key: process.env.LASTFM_API_KEY,
|
||||
format: "json",
|
||||
});
|
||||
|
||||
const res = await fetch(url);
|
||||
const text = await res.text();
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
console.error("Last.fm response was not JSON:");
|
||||
console.error(text.slice(0, 500));
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (!data.artist) return null;
|
||||
return data.artist;
|
||||
}
|
||||
|
||||
async function navidromeHasArtist(artist) {
|
||||
const salt = crypto.randomBytes(6).toString("hex");
|
||||
const token = crypto
|
||||
.createHash("md5")
|
||||
.update(process.env.NAVIDROME_PASS + salt)
|
||||
.digest("hex");
|
||||
|
||||
const url =
|
||||
`${process.env.NAVIDROME_URL}/rest/search3.view?` +
|
||||
new URLSearchParams({
|
||||
u: process.env.NAVIDROME_USER,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: "1.16.1",
|
||||
c: "discord-bot",
|
||||
query: artist,
|
||||
f: "json"
|
||||
});
|
||||
|
||||
const res = await fetch(url);
|
||||
const text = await res.text();
|
||||
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
console.error("Navidrome response was not JSON:");
|
||||
console.error(text.slice(0, 500));
|
||||
return false;
|
||||
}
|
||||
|
||||
const artists =
|
||||
json["subsonic-response"]?.searchResult3?.artist ?? [];
|
||||
|
||||
return artists.some(
|
||||
a => a.name.toLowerCase() === artist.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
client.on("interactionCreate", async interaction => {
|
||||
if (!interaction.isChatInputCommand()) return;
|
||||
if (interaction.commandName !== "request") return;
|
||||
|
||||
if (interaction.guildId !== GUILD_ID) {
|
||||
return interaction.reply({
|
||||
content: "❌ This command can only be used in the main server.",
|
||||
flags: {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const artistName = interaction.options.getString("artist");
|
||||
const url = interaction.options.getString("url");
|
||||
const description = interaction.options.getString("description");
|
||||
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
if (!artistName && !url) {
|
||||
return interaction.editReply({
|
||||
content: "❌ You must provide either an artist name or a SendGB URL.",
|
||||
});
|
||||
}
|
||||
|
||||
if (url) {
|
||||
if (!isValidSendGbUrl(url)) {
|
||||
return interaction.editReply({
|
||||
content: "❌ Invalid SendGB URL format.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!description) {
|
||||
return interaction.editReply({
|
||||
content: "❌ A description is required when submitting a URL.",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (artistName) {
|
||||
if (await navidromeHasArtist(artistName)) {
|
||||
return interaction.editReply({
|
||||
content: "❌ This artist already exists in Navidrome.",
|
||||
});
|
||||
}
|
||||
|
||||
const artist = await getLastFmArtist(artistName);
|
||||
if (!artist) {
|
||||
return interaction.editReply({
|
||||
content: "❌ Artist not found on Last.fm.",
|
||||
});
|
||||
}
|
||||
|
||||
const channel = await client.channels.fetch(REQUEST_CHANNEL_ID);
|
||||
|
||||
await channel.send({
|
||||
embeds: [
|
||||
{
|
||||
title: artist.name,
|
||||
url: artist.url,
|
||||
description:
|
||||
artist.bio?.summary?.replace(/<[^>]*>/g, "") ??
|
||||
"No description available.",
|
||||
thumbnail: {
|
||||
url:
|
||||
artist.image?.find(i => i.size === "extralarge")?.["#text"] ??
|
||||
null,
|
||||
},
|
||||
fields: [
|
||||
{ name: "Listeners", value: artist.stats.listeners, inline: true },
|
||||
{ name: "Playcount", value: artist.stats.playcount, inline: true },
|
||||
{
|
||||
name: "Tags",
|
||||
value:
|
||||
artist.tags?.tag?.map(t => t.name).join(", ") || "None",
|
||||
},
|
||||
{
|
||||
name: "Requested by",
|
||||
value: `<@${interaction.user.id}>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return interaction.editReply({
|
||||
content: "✅ Artist request submitted.",
|
||||
});
|
||||
}
|
||||
|
||||
const channel = await client.channels.fetch(REQUEST_CHANNEL_ID);
|
||||
|
||||
await channel.send({
|
||||
embeds: [
|
||||
{
|
||||
title: "📦 External Upload",
|
||||
description,
|
||||
fields: [
|
||||
{
|
||||
name: "Download",
|
||||
value: url,
|
||||
},
|
||||
{
|
||||
name: "Requested by",
|
||||
value: `<@${interaction.user.id}>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await interaction.editReply({
|
||||
content: "✅ Link submitted.",
|
||||
});
|
||||
});
|
||||
|
||||
/* --------------------------------- Ready -------------------------------- */
|
||||
|
||||
client.once("ready", () => {
|
||||
console.log(`Logged in as ${client.user.tag}`);
|
||||
});
|
||||
|
||||
client.login(process.env.DISCORD_TOKEN);
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue