commit 010598a8be7da49b6814537a58d0bf2bcee75523 Author: yourfriendoss Date: Sat Dec 20 12:02:36 2025 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..db8dc41 --- /dev/null +++ b/.env.example @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e2a891 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +deezer_cache +orpheus_links.txt +discord_send.txt + +node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..88f114d --- /dev/null +++ b/README.md @@ -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. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..8658773 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/misc/fix_dates_deezer_dl.sh b/misc/fix_dates_deezer_dl.sh new file mode 100644 index 0000000..7215121 --- /dev/null +++ b/misc/fix_dates_deezer_dl.sh @@ -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 diff --git a/misc/fix_singles.py b/misc/fix_singles.py new file mode 100644 index 0000000..e512404 --- /dev/null +++ b/misc/fix_singles.py @@ -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!") diff --git a/misc/fix_utf8_filenames_only.py b/misc/fix_utf8_filenames_only.py new file mode 100644 index 0000000..52a15a8 --- /dev/null +++ b/misc/fix_utf8_filenames_only.py @@ -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) diff --git a/misc/fix_utf8_tags.py b/misc/fix_utf8_tags.py new file mode 100644 index 0000000..841cd74 --- /dev/null +++ b/misc/fix_utf8_tags.py @@ -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}") diff --git a/misc/lrcput+txt.py b/misc/lrcput+txt.py new file mode 100644 index 0000000..a4b85fe --- /dev/null +++ b/misc/lrcput+txt.py @@ -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) diff --git a/package.json b/package.json new file mode 100644 index 0000000..1fc25ad --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/deezify.js b/src/deezify.js new file mode 100644 index 0000000..dc37c8c --- /dev/null +++ b/src/deezify.js @@ -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); +}); diff --git a/src/now-playing.js b/src/now-playing.js new file mode 100644 index 0000000..c1a6a62 --- /dev/null +++ b/src/now-playing.js @@ -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); diff --git a/src/pillow.js b/src/pillow.js new file mode 100644 index 0000000..b7858d1 --- /dev/null +++ b/src/pillow.js @@ -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); + } +})(); diff --git a/src/request-bot.js b/src/request-bot.js new file mode 100644 index 0000000..b3d58a0 --- /dev/null +++ b/src/request-bot.js @@ -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); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -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 + } +}