osu-play 1.3.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -4
- package/dist/cli.cjs +868 -91
- package/dist/cli.cjs.map +9 -6
- package/dist/cli.js +857 -80
- package/dist/cli.js.map +9 -6
- package/dist/core/api/catalog.d.ts +61 -0
- package/dist/core/api/server.d.ts +13 -0
- package/dist/core/cli/main.d.ts +6 -0
- package/dist/core/lazer/library.d.ts +17 -0
- package/dist/core/player/session.d.ts +8 -0
- package/dist/core/player/types.d.ts +1 -0
- package/package.json +5 -2
package/dist/cli.js
CHANGED
|
@@ -39,11 +39,11 @@ __export(exports_mod, {
|
|
|
39
39
|
getDataDir: () => getDataDir,
|
|
40
40
|
getConfigDir: () => getConfigDir
|
|
41
41
|
});
|
|
42
|
-
import
|
|
42
|
+
import path2 from "node:path";
|
|
43
43
|
import { existsSync } from "node:fs";
|
|
44
44
|
import { spawn } from "node:child_process";
|
|
45
45
|
function revealFile(filePath) {
|
|
46
|
-
const command = process.platform === "darwin" ? { args: ["-R", filePath], executable: "open" } : process.platform === "win32" ? { args: ["/select,", filePath], executable: "explorer.exe" } : { args: [
|
|
46
|
+
const command = process.platform === "darwin" ? { args: ["-R", filePath], executable: "open" } : process.platform === "win32" ? { args: ["/select,", filePath], executable: "explorer.exe" } : { args: [path2.dirname(filePath)], executable: "xdg-open" };
|
|
47
47
|
return new Promise((resolve, reject) => {
|
|
48
48
|
const child = spawn(command.executable, command.args, {
|
|
49
49
|
detached: true,
|
|
@@ -66,13 +66,13 @@ function getDataDir() {
|
|
|
66
66
|
return xdg;
|
|
67
67
|
const home = process.env.HOME;
|
|
68
68
|
if (home)
|
|
69
|
-
return
|
|
69
|
+
return path2.join(home, ".local", "share");
|
|
70
70
|
break;
|
|
71
71
|
}
|
|
72
72
|
case "darwin": {
|
|
73
73
|
const home = process.env.HOME;
|
|
74
74
|
if (home)
|
|
75
|
-
return
|
|
75
|
+
return path2.join(home, "Library", "Application Support");
|
|
76
76
|
break;
|
|
77
77
|
}
|
|
78
78
|
case "win32":
|
|
@@ -90,13 +90,13 @@ function getConfigDir() {
|
|
|
90
90
|
return xdg;
|
|
91
91
|
const home = process.env.HOME;
|
|
92
92
|
if (home)
|
|
93
|
-
return
|
|
93
|
+
return path2.join(home, ".config");
|
|
94
94
|
break;
|
|
95
95
|
}
|
|
96
96
|
case "darwin": {
|
|
97
97
|
const home = process.env.HOME;
|
|
98
98
|
if (home)
|
|
99
|
-
return
|
|
99
|
+
return path2.join(home, "Library", "Preferences");
|
|
100
100
|
break;
|
|
101
101
|
}
|
|
102
102
|
case "win32":
|
|
@@ -105,18 +105,18 @@ function getConfigDir() {
|
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
107
|
function getDefaultOsuDataDir() {
|
|
108
|
-
return
|
|
108
|
+
return path2.join(getDataDir() ?? ".", "osu");
|
|
109
109
|
}
|
|
110
110
|
function getDefaultConfigPath(appName = "osu-play") {
|
|
111
|
-
return
|
|
111
|
+
return path2.join(getConfigDir() ?? ".", appName);
|
|
112
112
|
}
|
|
113
113
|
function hashedFilePath(hash, osuDataDir = getDefaultOsuDataDir()) {
|
|
114
|
-
return
|
|
114
|
+
return path2.join(osuDataDir, "files", hash.slice(0, 1), hash.slice(0, 2), hash);
|
|
115
115
|
}
|
|
116
116
|
function getRealmDBPath(legacyAppConfigDirOrOptions = {}, maybeOptions = {}) {
|
|
117
117
|
const options = typeof legacyAppConfigDirOrOptions === "string" ? maybeOptions : legacyAppConfigDirOrOptions;
|
|
118
118
|
const osuDataDir = options.osuDataDir ?? getDefaultOsuDataDir();
|
|
119
|
-
const osuDBPath =
|
|
119
|
+
const osuDBPath = path2.join(osuDataDir, "client.realm");
|
|
120
120
|
if (!existsSync(osuDBPath)) {
|
|
121
121
|
console.log(`[getRealmDBPath]: ${osuDBPath} not found`);
|
|
122
122
|
return null;
|
|
@@ -514,12 +514,615 @@ var init_mod3 = __esm(() => {
|
|
|
514
514
|
});
|
|
515
515
|
|
|
516
516
|
// src/core/cli/main.ts
|
|
517
|
-
import
|
|
517
|
+
import path4 from "node:path";
|
|
518
518
|
import { writeFileSync } from "node:fs";
|
|
519
519
|
import { ProcessTerminal, TUI } from "@earendil-works/pi-tui";
|
|
520
520
|
import yargs from "yargs/yargs";
|
|
521
521
|
import { hideBin } from "yargs/helpers";
|
|
522
522
|
|
|
523
|
+
// src/core/api/server.ts
|
|
524
|
+
import { createReadStream } from "node:fs";
|
|
525
|
+
import { stat } from "node:fs/promises";
|
|
526
|
+
import { createServer } from "node:http";
|
|
527
|
+
import path from "node:path";
|
|
528
|
+
var API_VERSION = "1.16.1";
|
|
529
|
+
var SERVER_VERSION = "1.4.0";
|
|
530
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
531
|
+
var DEFAULT_PORT = 4533;
|
|
532
|
+
function responseBase(status) {
|
|
533
|
+
return {
|
|
534
|
+
openSubsonic: true,
|
|
535
|
+
serverVersion: SERVER_VERSION,
|
|
536
|
+
status,
|
|
537
|
+
type: "osu-play",
|
|
538
|
+
version: API_VERSION
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function sendJson(response, body, statusCode = 200) {
|
|
542
|
+
const encoded = JSON.stringify(body);
|
|
543
|
+
response.writeHead(statusCode, {
|
|
544
|
+
"Cache-Control": "no-store",
|
|
545
|
+
"Content-Length": Buffer.byteLength(encoded),
|
|
546
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
547
|
+
});
|
|
548
|
+
response.end(encoded);
|
|
549
|
+
}
|
|
550
|
+
function sendSuccess(response, data = {}) {
|
|
551
|
+
sendJson(response, {
|
|
552
|
+
"subsonic-response": {
|
|
553
|
+
...responseBase("ok"),
|
|
554
|
+
...data
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
function sendFailure(response, error, statusCode = 200) {
|
|
559
|
+
sendJson(response, {
|
|
560
|
+
"subsonic-response": {
|
|
561
|
+
...responseBase("failed"),
|
|
562
|
+
error
|
|
563
|
+
}
|
|
564
|
+
}, statusCode);
|
|
565
|
+
}
|
|
566
|
+
function requiredParameter(response, params, name) {
|
|
567
|
+
const value = params.get(name)?.trim();
|
|
568
|
+
if (!value) {
|
|
569
|
+
sendFailure(response, {
|
|
570
|
+
code: 10,
|
|
571
|
+
message: `Required parameter '${name}' is missing.`
|
|
572
|
+
});
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
return value;
|
|
576
|
+
}
|
|
577
|
+
function hasValidCredentials(params) {
|
|
578
|
+
const username = params.get("u")?.trim();
|
|
579
|
+
const token = params.get("t")?.trim();
|
|
580
|
+
const salt = params.get("s")?.trim();
|
|
581
|
+
return Boolean(username && token && salt && /^[a-f0-9]{32}$/i.test(token) && params.get("f") === "json" && params.get("c")?.trim() && params.get("v")?.trim());
|
|
582
|
+
}
|
|
583
|
+
function parseNonNegativeInteger(value, fallback, maximum) {
|
|
584
|
+
if (value === null) {
|
|
585
|
+
return fallback;
|
|
586
|
+
}
|
|
587
|
+
if (!/^\d+$/.test(value)) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
const parsed = Number(value);
|
|
591
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
return maximum === undefined ? parsed : Math.min(parsed, maximum);
|
|
595
|
+
}
|
|
596
|
+
function albumPayload(album) {
|
|
597
|
+
return {
|
|
598
|
+
artist: album.artist,
|
|
599
|
+
...album.coverArt ? { coverArt: album.coverArt } : {},
|
|
600
|
+
id: album.id,
|
|
601
|
+
name: album.name
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
function songPayload(song) {
|
|
605
|
+
return {
|
|
606
|
+
album: song.album,
|
|
607
|
+
albumId: song.albumId,
|
|
608
|
+
artist: song.artist,
|
|
609
|
+
...song.coverArt ? { coverArt: song.coverArt } : {},
|
|
610
|
+
...song.duration ? { duration: song.duration } : {},
|
|
611
|
+
id: song.id,
|
|
612
|
+
title: song.title,
|
|
613
|
+
track: song.track
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
function artistIndex(catalog) {
|
|
617
|
+
const groups = new Map;
|
|
618
|
+
for (const artist of catalog.artists) {
|
|
619
|
+
const firstCharacter = artist.name.trim().charAt(0).toUpperCase();
|
|
620
|
+
const name = /^[A-Z]$/.test(firstCharacter) ? firstCharacter : "#";
|
|
621
|
+
const group = groups.get(name) ?? [];
|
|
622
|
+
group.push({
|
|
623
|
+
...artist.coverArt ? { coverArt: artist.coverArt } : {},
|
|
624
|
+
id: artist.id,
|
|
625
|
+
name: artist.name
|
|
626
|
+
});
|
|
627
|
+
groups.set(name, group);
|
|
628
|
+
}
|
|
629
|
+
return [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, artist]) => ({ artist, name }));
|
|
630
|
+
}
|
|
631
|
+
function contentType(fileName) {
|
|
632
|
+
switch (path.extname(fileName).toLowerCase()) {
|
|
633
|
+
case ".aac":
|
|
634
|
+
return "audio/aac";
|
|
635
|
+
case ".flac":
|
|
636
|
+
return "audio/flac";
|
|
637
|
+
case ".jpeg":
|
|
638
|
+
case ".jpg":
|
|
639
|
+
return "image/jpeg";
|
|
640
|
+
case ".m4a":
|
|
641
|
+
case ".mp4":
|
|
642
|
+
return "audio/mp4";
|
|
643
|
+
case ".mp3":
|
|
644
|
+
return "audio/mpeg";
|
|
645
|
+
case ".ogg":
|
|
646
|
+
case ".oga":
|
|
647
|
+
case ".opus":
|
|
648
|
+
return "audio/ogg";
|
|
649
|
+
case ".png":
|
|
650
|
+
return "image/png";
|
|
651
|
+
case ".wav":
|
|
652
|
+
return "audio/wav";
|
|
653
|
+
case ".webm":
|
|
654
|
+
return "audio/webm";
|
|
655
|
+
case ".webp":
|
|
656
|
+
return "image/webp";
|
|
657
|
+
default:
|
|
658
|
+
return "application/octet-stream";
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
function parseRange(rangeHeader, size) {
|
|
662
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
|
663
|
+
if (!match || !match[1] && !match[2]) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
if (!match[1]) {
|
|
667
|
+
const suffixLength = Number(match[2]);
|
|
668
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
end: size - 1,
|
|
673
|
+
start: Math.max(0, size - suffixLength)
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
const start = Number(match[1]);
|
|
677
|
+
const requestedEnd = match[2] ? Number(match[2]) : size - 1;
|
|
678
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start < 0 || requestedEnd < start || start >= size) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
end: Math.min(requestedEnd, size - 1),
|
|
683
|
+
start
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
async function serveMedia(request, response, media, etag) {
|
|
687
|
+
let file;
|
|
688
|
+
try {
|
|
689
|
+
file = await stat(media.path);
|
|
690
|
+
} catch {
|
|
691
|
+
sendFailure(response, {
|
|
692
|
+
code: 70,
|
|
693
|
+
message: "Requested media is no longer available."
|
|
694
|
+
}, 404);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (!file.isFile()) {
|
|
698
|
+
sendFailure(response, {
|
|
699
|
+
code: 70,
|
|
700
|
+
message: "Requested media is not a file."
|
|
701
|
+
}, 404);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const commonHeaders = {
|
|
705
|
+
"Accept-Ranges": "bytes",
|
|
706
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
707
|
+
"Content-Type": contentType(media.fileName),
|
|
708
|
+
ETag: `"${etag}"`
|
|
709
|
+
};
|
|
710
|
+
const rangeHeader = request.headers.range;
|
|
711
|
+
const range = rangeHeader ? parseRange(rangeHeader, file.size) : undefined;
|
|
712
|
+
if (rangeHeader && !range) {
|
|
713
|
+
response.writeHead(416, {
|
|
714
|
+
...commonHeaders,
|
|
715
|
+
"Content-Range": `bytes */${file.size}`
|
|
716
|
+
});
|
|
717
|
+
response.end();
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const start = range?.start ?? 0;
|
|
721
|
+
const end = range?.end ?? file.size - 1;
|
|
722
|
+
const contentLength = Math.max(0, end - start + 1);
|
|
723
|
+
response.writeHead(range ? 206 : 200, {
|
|
724
|
+
...commonHeaders,
|
|
725
|
+
"Content-Length": contentLength,
|
|
726
|
+
...range ? { "Content-Range": `bytes ${start}-${end}/${file.size}` } : {}
|
|
727
|
+
});
|
|
728
|
+
if (request.method === "HEAD" || file.size === 0) {
|
|
729
|
+
response.end();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const stream = createReadStream(media.path, { end, start });
|
|
733
|
+
const destroyStream = () => stream.destroy();
|
|
734
|
+
response.once("close", destroyStream);
|
|
735
|
+
stream.once("error", (error) => {
|
|
736
|
+
response.destroy(error);
|
|
737
|
+
});
|
|
738
|
+
stream.once("close", () => {
|
|
739
|
+
response.off("close", destroyStream);
|
|
740
|
+
});
|
|
741
|
+
stream.pipe(response);
|
|
742
|
+
}
|
|
743
|
+
async function routeRequest(request, response, catalogStore) {
|
|
744
|
+
const requestUrl = new URL(request.url ?? "/", `http://${DEFAULT_HOST}`);
|
|
745
|
+
const match = /^\/rest\/([A-Za-z0-9]+)\.view$/.exec(requestUrl.pathname);
|
|
746
|
+
if (!match) {
|
|
747
|
+
sendFailure(response, {
|
|
748
|
+
code: 0,
|
|
749
|
+
message: "Unsupported API endpoint."
|
|
750
|
+
}, 404);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const endpoint = match[1];
|
|
754
|
+
const isMedia = endpoint === "stream" || endpoint === "getCoverArt";
|
|
755
|
+
const allowedMethod = request.method === "GET" || isMedia && request.method === "HEAD";
|
|
756
|
+
if (!allowedMethod) {
|
|
757
|
+
response.setHeader("Allow", isMedia ? "GET, HEAD" : "GET");
|
|
758
|
+
sendFailure(response, {
|
|
759
|
+
code: 0,
|
|
760
|
+
message: "Unsupported HTTP method."
|
|
761
|
+
}, 405);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (!hasValidCredentials(requestUrl.searchParams)) {
|
|
765
|
+
sendFailure(response, {
|
|
766
|
+
code: 40,
|
|
767
|
+
message: "Wrong username or password."
|
|
768
|
+
});
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (endpoint === "getArtists" || endpoint === "getAlbumList2") {
|
|
772
|
+
const offset = endpoint === "getAlbumList2" ? parseNonNegativeInteger(requestUrl.searchParams.get("offset"), 0) : 0;
|
|
773
|
+
if (offset === 0) {
|
|
774
|
+
await catalogStore.refreshIfChanged();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const catalog = catalogStore.getCatalog();
|
|
778
|
+
switch (endpoint) {
|
|
779
|
+
case "ping":
|
|
780
|
+
sendSuccess(response);
|
|
781
|
+
return;
|
|
782
|
+
case "getArtists":
|
|
783
|
+
sendSuccess(response, {
|
|
784
|
+
artists: {
|
|
785
|
+
index: artistIndex(catalog)
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
return;
|
|
789
|
+
case "getAlbumList2": {
|
|
790
|
+
if (requestUrl.searchParams.get("type") !== "alphabeticalByName") {
|
|
791
|
+
sendFailure(response, {
|
|
792
|
+
code: 10,
|
|
793
|
+
message: "Only type=alphabeticalByName is supported."
|
|
794
|
+
});
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const offset = parseNonNegativeInteger(requestUrl.searchParams.get("offset"), 0);
|
|
798
|
+
const size = parseNonNegativeInteger(requestUrl.searchParams.get("size"), 10, 500);
|
|
799
|
+
if (offset === null || size === null) {
|
|
800
|
+
sendFailure(response, {
|
|
801
|
+
code: 10,
|
|
802
|
+
message: "Album offset and size must be non-negative integers."
|
|
803
|
+
});
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
sendSuccess(response, {
|
|
807
|
+
albumList2: {
|
|
808
|
+
album: catalog.albums.slice(offset, offset + size).map(albumPayload)
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
case "getAlbum": {
|
|
814
|
+
const id = requiredParameter(response, requestUrl.searchParams, "id");
|
|
815
|
+
if (!id)
|
|
816
|
+
return;
|
|
817
|
+
const album = catalog.albumsById.get(id);
|
|
818
|
+
if (!album) {
|
|
819
|
+
sendFailure(response, {
|
|
820
|
+
code: 70,
|
|
821
|
+
message: "Album not found."
|
|
822
|
+
});
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
sendSuccess(response, {
|
|
826
|
+
album: {
|
|
827
|
+
...albumPayload(album),
|
|
828
|
+
song: album.songs.map(songPayload)
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
case "stream": {
|
|
834
|
+
const id = requiredParameter(response, requestUrl.searchParams, "id");
|
|
835
|
+
if (!id)
|
|
836
|
+
return;
|
|
837
|
+
const song = catalog.songsById.get(id);
|
|
838
|
+
if (!song) {
|
|
839
|
+
sendFailure(response, {
|
|
840
|
+
code: 70,
|
|
841
|
+
message: "Song not found."
|
|
842
|
+
}, 404);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
await serveMedia(request, response, {
|
|
846
|
+
fileName: song.fileName,
|
|
847
|
+
path: song.path
|
|
848
|
+
}, song.id);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
case "getCoverArt": {
|
|
852
|
+
const id = requiredParameter(response, requestUrl.searchParams, "id");
|
|
853
|
+
if (!id)
|
|
854
|
+
return;
|
|
855
|
+
const cover = catalog.coversById.get(id);
|
|
856
|
+
if (!cover) {
|
|
857
|
+
sendFailure(response, {
|
|
858
|
+
code: 70,
|
|
859
|
+
message: "Cover art not found."
|
|
860
|
+
}, 404);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
await serveMedia(request, response, cover, id);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
case "getPlaylists":
|
|
867
|
+
sendSuccess(response, {
|
|
868
|
+
playlists: {
|
|
869
|
+
playlist: []
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
return;
|
|
873
|
+
case "getStarred2":
|
|
874
|
+
sendSuccess(response, {
|
|
875
|
+
starred2: {
|
|
876
|
+
song: []
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
return;
|
|
880
|
+
case "scrobble": {
|
|
881
|
+
const id = requiredParameter(response, requestUrl.searchParams, "id");
|
|
882
|
+
if (!id)
|
|
883
|
+
return;
|
|
884
|
+
if (!catalog.songsById.has(id)) {
|
|
885
|
+
sendFailure(response, {
|
|
886
|
+
code: 70,
|
|
887
|
+
message: "Song not found."
|
|
888
|
+
});
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
sendSuccess(response);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
case "getLyricsBySongId":
|
|
895
|
+
sendSuccess(response, {
|
|
896
|
+
lyricsList: {
|
|
897
|
+
structuredLyrics: []
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
return;
|
|
901
|
+
case "getLyrics":
|
|
902
|
+
sendSuccess(response, {
|
|
903
|
+
lyrics: {
|
|
904
|
+
value: ""
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
return;
|
|
908
|
+
case "getPlaylist":
|
|
909
|
+
sendFailure(response, {
|
|
910
|
+
code: 70,
|
|
911
|
+
message: "Playlist not found; osu-play API mode is read-only."
|
|
912
|
+
});
|
|
913
|
+
return;
|
|
914
|
+
case "createPlaylist":
|
|
915
|
+
case "updatePlaylist":
|
|
916
|
+
case "star":
|
|
917
|
+
case "unstar":
|
|
918
|
+
sendFailure(response, {
|
|
919
|
+
code: 0,
|
|
920
|
+
message: "This operation is not supported by read-only osu-play API mode."
|
|
921
|
+
});
|
|
922
|
+
return;
|
|
923
|
+
default:
|
|
924
|
+
sendFailure(response, {
|
|
925
|
+
code: 0,
|
|
926
|
+
message: `Endpoint '${endpoint}.view' is not supported.`
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
async function startApiServer(options) {
|
|
931
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
932
|
+
const requestedPort = options.port ?? DEFAULT_PORT;
|
|
933
|
+
const server = createServer((request, response) => {
|
|
934
|
+
routeRequest(request, response, options.catalogStore).catch((error) => {
|
|
935
|
+
if (!response.headersSent) {
|
|
936
|
+
sendFailure(response, {
|
|
937
|
+
code: 0,
|
|
938
|
+
message: "Internal server error."
|
|
939
|
+
}, 500);
|
|
940
|
+
} else {
|
|
941
|
+
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
server.requestTimeout = 30000;
|
|
946
|
+
server.headersTimeout = 1e4;
|
|
947
|
+
await new Promise((resolve, reject) => {
|
|
948
|
+
const handleError = (error) => {
|
|
949
|
+
server.off("listening", handleListening);
|
|
950
|
+
reject(error);
|
|
951
|
+
};
|
|
952
|
+
const handleListening = () => {
|
|
953
|
+
server.off("error", handleError);
|
|
954
|
+
resolve();
|
|
955
|
+
};
|
|
956
|
+
server.once("error", handleError);
|
|
957
|
+
server.once("listening", handleListening);
|
|
958
|
+
server.listen(requestedPort, host);
|
|
959
|
+
});
|
|
960
|
+
const address = server.address();
|
|
961
|
+
if (!address || typeof address === "string") {
|
|
962
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
963
|
+
throw new Error("Could not determine the API server address.");
|
|
964
|
+
}
|
|
965
|
+
const port = address.port;
|
|
966
|
+
return {
|
|
967
|
+
async close() {
|
|
968
|
+
if (!server.listening) {
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
await new Promise((resolve, reject) => {
|
|
972
|
+
server.close((error) => {
|
|
973
|
+
if (error)
|
|
974
|
+
reject(error);
|
|
975
|
+
else
|
|
976
|
+
resolve();
|
|
977
|
+
});
|
|
978
|
+
server.closeIdleConnections();
|
|
979
|
+
});
|
|
980
|
+
},
|
|
981
|
+
host,
|
|
982
|
+
port,
|
|
983
|
+
url: `http://${host}:${port}`
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// src/core/lazer/library.ts
|
|
988
|
+
import { stat as stat2 } from "node:fs/promises";
|
|
989
|
+
|
|
990
|
+
// src/core/api/catalog.ts
|
|
991
|
+
init_mod();
|
|
992
|
+
import { createHash } from "node:crypto";
|
|
993
|
+
function stableId(kind, value) {
|
|
994
|
+
return createHash("sha256").update(`${kind}\x00${value}`).digest("hex");
|
|
995
|
+
}
|
|
996
|
+
function normalizedParts(parts) {
|
|
997
|
+
return [...new Set(parts.map((part) => part?.trim()).filter(Boolean))];
|
|
998
|
+
}
|
|
999
|
+
function formatMetadataText(primary, unicode, fallback) {
|
|
1000
|
+
return normalizedParts([primary, unicode]).join(" / ") || fallback;
|
|
1001
|
+
}
|
|
1002
|
+
function getNamedFile(fileName, beatmapSet) {
|
|
1003
|
+
if (!fileName) {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
for (const file of beatmapSet.Files) {
|
|
1007
|
+
if (file.Filename === fileName && file.File?.Hash) {
|
|
1008
|
+
return {
|
|
1009
|
+
fileName,
|
|
1010
|
+
hash: file.File.Hash
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
function beatmapSetKey(beatmapSet) {
|
|
1017
|
+
return String(beatmapSet.ID ?? beatmapSet.Hash ?? "");
|
|
1018
|
+
}
|
|
1019
|
+
function compareText(left, right) {
|
|
1020
|
+
return left.localeCompare(right, undefined, {
|
|
1021
|
+
sensitivity: "base",
|
|
1022
|
+
usage: "sort"
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
function buildMusicCatalog(beatmapSets, osuDataDir) {
|
|
1026
|
+
const albums = [];
|
|
1027
|
+
const coversById = new Map;
|
|
1028
|
+
const seenAudioHashes = new Set;
|
|
1029
|
+
const sortedSets = [...beatmapSets].filter((beatmapSet) => !beatmapSet.DeletePending).sort((left, right) => compareText(beatmapSetKey(left), beatmapSetKey(right)));
|
|
1030
|
+
for (const beatmapSet of sortedSets) {
|
|
1031
|
+
const pendingByHash = new Map;
|
|
1032
|
+
let albumCoverArt;
|
|
1033
|
+
for (const beatmap of beatmapSet.Beatmaps) {
|
|
1034
|
+
const metadata = beatmap.Metadata;
|
|
1035
|
+
const audio = getNamedFile(metadata.AudioFile, beatmapSet);
|
|
1036
|
+
if (!audio) {
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
const background = getNamedFile(metadata.BackgroundFile, beatmapSet);
|
|
1040
|
+
if (background) {
|
|
1041
|
+
coversById.set(background.hash, {
|
|
1042
|
+
fileName: background.fileName,
|
|
1043
|
+
path: hashedFilePath(background.hash, osuDataDir)
|
|
1044
|
+
});
|
|
1045
|
+
albumCoverArt ??= background.hash;
|
|
1046
|
+
}
|
|
1047
|
+
const durationMs = Math.max(0, beatmap.Length ?? 0);
|
|
1048
|
+
const existing = pendingByHash.get(audio.hash);
|
|
1049
|
+
if (existing) {
|
|
1050
|
+
existing.durationMs = Math.max(existing.durationMs, durationMs);
|
|
1051
|
+
existing.coverArt ??= background?.hash;
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
pendingByHash.set(audio.hash, {
|
|
1055
|
+
artist: formatMetadataText(metadata.Artist, metadata.ArtistUnicode, "Unknown Artist"),
|
|
1056
|
+
coverArt: background?.hash,
|
|
1057
|
+
durationMs,
|
|
1058
|
+
fileName: audio.fileName,
|
|
1059
|
+
hash: audio.hash,
|
|
1060
|
+
title: formatMetadataText(metadata.Title, metadata.TitleUnicode, "Unknown Title")
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
const retained = [...pendingByHash.values()].filter((song) => !seenAudioHashes.has(song.hash));
|
|
1064
|
+
if (retained.length === 0) {
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
for (const song of retained) {
|
|
1068
|
+
seenAudioHashes.add(song.hash);
|
|
1069
|
+
}
|
|
1070
|
+
const firstSong = retained[0];
|
|
1071
|
+
const setKey = beatmapSetKey(beatmapSet) || firstSong.hash;
|
|
1072
|
+
const albumId = stableId("album", setKey);
|
|
1073
|
+
const albumName = firstSong.title;
|
|
1074
|
+
const albumArtist = firstSong.artist;
|
|
1075
|
+
const coverArt = albumCoverArt ?? firstSong.coverArt;
|
|
1076
|
+
const songs = retained.map((song, index) => {
|
|
1077
|
+
const duration = Math.round(song.durationMs / 1000);
|
|
1078
|
+
return {
|
|
1079
|
+
album: albumName,
|
|
1080
|
+
albumId,
|
|
1081
|
+
artist: song.artist,
|
|
1082
|
+
...coverArt ? { coverArt } : {},
|
|
1083
|
+
...duration > 0 ? { duration } : {},
|
|
1084
|
+
fileName: song.fileName,
|
|
1085
|
+
id: song.hash,
|
|
1086
|
+
path: hashedFilePath(song.hash, osuDataDir),
|
|
1087
|
+
title: song.title,
|
|
1088
|
+
track: index + 1
|
|
1089
|
+
};
|
|
1090
|
+
});
|
|
1091
|
+
albums.push({
|
|
1092
|
+
artist: albumArtist,
|
|
1093
|
+
...coverArt ? { coverArt } : {},
|
|
1094
|
+
id: albumId,
|
|
1095
|
+
name: albumName,
|
|
1096
|
+
songs
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
albums.sort((left, right) => compareText(left.name, right.name) || left.id.localeCompare(right.id));
|
|
1100
|
+
const albumsById = new Map(albums.map((album) => [album.id, album]));
|
|
1101
|
+
const songsById = new Map(albums.flatMap((album) => album.songs.map((song) => [song.id, song])));
|
|
1102
|
+
const artistsByName = new Map;
|
|
1103
|
+
for (const album of albums) {
|
|
1104
|
+
const normalizedName = album.artist.trim().toLocaleLowerCase();
|
|
1105
|
+
const existing = artistsByName.get(normalizedName);
|
|
1106
|
+
if (existing) {
|
|
1107
|
+
existing.coverArt ??= album.coverArt;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
artistsByName.set(normalizedName, {
|
|
1111
|
+
...album.coverArt ? { coverArt: album.coverArt } : {},
|
|
1112
|
+
id: stableId("artist", normalizedName),
|
|
1113
|
+
name: album.artist
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
const artists = [...artistsByName.values()].sort((left, right) => compareText(left.name, right.name) || left.id.localeCompare(right.id));
|
|
1117
|
+
return {
|
|
1118
|
+
albums,
|
|
1119
|
+
albumsById,
|
|
1120
|
+
artists,
|
|
1121
|
+
coversById,
|
|
1122
|
+
songsById
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
523
1126
|
// src/core/playlist/mod.ts
|
|
524
1127
|
init_mod();
|
|
525
1128
|
function uniqueParts(parts) {
|
|
@@ -564,12 +1167,123 @@ function buildPlaylist(beatmapSets, osuDataDir) {
|
|
|
564
1167
|
return playlist;
|
|
565
1168
|
}
|
|
566
1169
|
|
|
1170
|
+
// src/core/lazer/library.ts
|
|
1171
|
+
init_mod();
|
|
1172
|
+
function formatRealmLoadError(error) {
|
|
1173
|
+
return [
|
|
1174
|
+
"Realm native bindings could not be loaded.",
|
|
1175
|
+
`Current Node runtime: ${process.version}.`,
|
|
1176
|
+
"To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
|
|
1177
|
+
"If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
|
|
1178
|
+
`Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
1179
|
+
].join(`
|
|
1180
|
+
`);
|
|
1181
|
+
}
|
|
1182
|
+
async function loadRealmDependencies() {
|
|
1183
|
+
try {
|
|
1184
|
+
const [{ default: Realm8 }, lazerModule] = await Promise.all([
|
|
1185
|
+
import("realm"),
|
|
1186
|
+
Promise.resolve().then(() => (init_mod3(), exports_mod2))
|
|
1187
|
+
]);
|
|
1188
|
+
return {
|
|
1189
|
+
Realm: Realm8,
|
|
1190
|
+
...lazerModule
|
|
1191
|
+
};
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
throw new Error(formatRealmLoadError(error), { cause: error });
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
async function projectLazerLibrary(osuDataDir, projector) {
|
|
1197
|
+
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
1198
|
+
if (!realmDBPath) {
|
|
1199
|
+
throw new Error("Realm DB not found");
|
|
1200
|
+
}
|
|
1201
|
+
const {
|
|
1202
|
+
Realm: Realm8,
|
|
1203
|
+
formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
|
|
1204
|
+
getBeatmapSets: getBeatmapSets2,
|
|
1205
|
+
getLazerDB: getLazerDB2,
|
|
1206
|
+
inspectLazerSchema: inspectLazerSchema2
|
|
1207
|
+
} = await loadRealmDependencies();
|
|
1208
|
+
Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
|
|
1209
|
+
const realm = await getLazerDB2(realmDBPath);
|
|
1210
|
+
try {
|
|
1211
|
+
const schemaReport = inspectLazerSchema2(realm);
|
|
1212
|
+
if (!schemaReport.compatible) {
|
|
1213
|
+
throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
|
|
1214
|
+
}
|
|
1215
|
+
return projector(getBeatmapSets2(realm));
|
|
1216
|
+
} finally {
|
|
1217
|
+
realm.close();
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function loadPlaylistFromLazer(osuDataDir) {
|
|
1221
|
+
return projectLazerLibrary(osuDataDir, (beatmapSets) => buildPlaylist(beatmapSets, osuDataDir));
|
|
1222
|
+
}
|
|
1223
|
+
function loadMusicCatalogFromLazer(osuDataDir) {
|
|
1224
|
+
return projectLazerLibrary(osuDataDir, (beatmapSets) => buildMusicCatalog(beatmapSets, osuDataDir));
|
|
1225
|
+
}
|
|
1226
|
+
async function deleteBeatmapSetFromLazer(track, osuDataDir) {
|
|
1227
|
+
const { deleteBeatmapSet: deleteBeatmapSet2 } = await loadRealmDependencies();
|
|
1228
|
+
await deleteBeatmapSet2(track, osuDataDir);
|
|
1229
|
+
}
|
|
1230
|
+
async function createRefreshingCatalogStore(options) {
|
|
1231
|
+
let catalog = await options.loadCatalog();
|
|
1232
|
+
let signature = await options.getSignature();
|
|
1233
|
+
let refreshPromise = null;
|
|
1234
|
+
return {
|
|
1235
|
+
getCatalog() {
|
|
1236
|
+
return catalog;
|
|
1237
|
+
},
|
|
1238
|
+
async refreshIfChanged() {
|
|
1239
|
+
if (refreshPromise) {
|
|
1240
|
+
return refreshPromise;
|
|
1241
|
+
}
|
|
1242
|
+
refreshPromise = (async () => {
|
|
1243
|
+
try {
|
|
1244
|
+
const nextSignature = await options.getSignature();
|
|
1245
|
+
if (nextSignature === signature) {
|
|
1246
|
+
return catalog;
|
|
1247
|
+
}
|
|
1248
|
+
const nextCatalog = await options.loadCatalog();
|
|
1249
|
+
catalog = nextCatalog;
|
|
1250
|
+
signature = nextSignature;
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
options.onRefreshError?.(error);
|
|
1253
|
+
}
|
|
1254
|
+
return catalog;
|
|
1255
|
+
})();
|
|
1256
|
+
try {
|
|
1257
|
+
return await refreshPromise;
|
|
1258
|
+
} finally {
|
|
1259
|
+
refreshPromise = null;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
async function createLazerCatalogStore(osuDataDir, onRefreshError = (error) => {
|
|
1265
|
+
console.warn(`[WARN] Could not refresh the osu!lazer catalog; continuing with the last good snapshot: ${error instanceof Error ? error.message : String(error)}`);
|
|
1266
|
+
}) {
|
|
1267
|
+
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
1268
|
+
if (!realmDBPath) {
|
|
1269
|
+
throw new Error("Realm DB not found");
|
|
1270
|
+
}
|
|
1271
|
+
return createRefreshingCatalogStore({
|
|
1272
|
+
async getSignature() {
|
|
1273
|
+
const file = await stat2(realmDBPath);
|
|
1274
|
+
return `${file.mtimeMs}:${file.size}`;
|
|
1275
|
+
},
|
|
1276
|
+
loadCatalog: () => loadMusicCatalogFromLazer(osuDataDir),
|
|
1277
|
+
onRefreshError
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
|
|
567
1281
|
// src/core/player/mpv.ts
|
|
568
1282
|
import { spawn as spawn2 } from "node:child_process";
|
|
569
1283
|
import { existsSync as existsSync2, rmSync } from "node:fs";
|
|
570
1284
|
import net from "node:net";
|
|
571
1285
|
import os from "node:os";
|
|
572
|
-
import
|
|
1286
|
+
import path3 from "node:path";
|
|
573
1287
|
import { setTimeout as delay } from "node:timers/promises";
|
|
574
1288
|
function getErrorCode(error) {
|
|
575
1289
|
if (error && typeof error === "object" && "code" in error) {
|
|
@@ -591,7 +1305,7 @@ function createSocketPath() {
|
|
|
591
1305
|
if (process.platform === "win32") {
|
|
592
1306
|
return `\\\\.\\pipe\\${id}`;
|
|
593
1307
|
}
|
|
594
|
-
return
|
|
1308
|
+
return path3.join(os.tmpdir(), `${id}.sock`);
|
|
595
1309
|
}
|
|
596
1310
|
function isControlText(value) {
|
|
597
1311
|
return [...value].some((character) => {
|
|
@@ -1015,10 +1729,13 @@ class PlaylistPlayerSession {
|
|
|
1015
1729
|
loop;
|
|
1016
1730
|
deleteTrack;
|
|
1017
1731
|
playlist;
|
|
1732
|
+
random;
|
|
1018
1733
|
reloadPlaylist;
|
|
1019
1734
|
revealTrack;
|
|
1020
1735
|
searchQuery = "";
|
|
1021
1736
|
selectedIndex = 0;
|
|
1737
|
+
shuffle;
|
|
1738
|
+
shuffleOrder = [];
|
|
1022
1739
|
operationQueue = Promise.resolve();
|
|
1023
1740
|
unsubscribeBackend;
|
|
1024
1741
|
constructor(playlist, backend, options = {}) {
|
|
@@ -1026,8 +1743,13 @@ class PlaylistPlayerSession {
|
|
|
1026
1743
|
this.playlist = playlist;
|
|
1027
1744
|
this.deleteTrack = options.deleteTrack;
|
|
1028
1745
|
this.loop = options.loop ?? false;
|
|
1746
|
+
this.random = options.random ?? Math.random;
|
|
1029
1747
|
this.reloadPlaylist = options.reloadPlaylist;
|
|
1030
1748
|
this.revealTrack = options.revealTrack;
|
|
1749
|
+
this.shuffle = options.shuffle ?? false;
|
|
1750
|
+
if (this.shuffle) {
|
|
1751
|
+
this.resetShuffleOrder(this.selectedIndex);
|
|
1752
|
+
}
|
|
1031
1753
|
this.unsubscribeBackend = backend.subscribe((event) => {
|
|
1032
1754
|
this.handleBackendEvent(event);
|
|
1033
1755
|
});
|
|
@@ -1045,6 +1767,7 @@ class PlaylistPlayerSession {
|
|
|
1045
1767
|
playlist: this.playlist,
|
|
1046
1768
|
searchQuery: this.searchQuery,
|
|
1047
1769
|
selectedIndex: this.selectedIndex,
|
|
1770
|
+
shuffle: this.shuffle,
|
|
1048
1771
|
status: backendSnapshot.status,
|
|
1049
1772
|
timePositionSeconds: backendSnapshot.timePositionSeconds
|
|
1050
1773
|
};
|
|
@@ -1119,30 +1842,43 @@ class PlaylistPlayerSession {
|
|
|
1119
1842
|
this.loop = !this.loop;
|
|
1120
1843
|
this.emit();
|
|
1121
1844
|
}
|
|
1845
|
+
toggleShuffle() {
|
|
1846
|
+
this.shuffle = !this.shuffle;
|
|
1847
|
+
if (this.shuffle) {
|
|
1848
|
+
this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
|
|
1849
|
+
}
|
|
1850
|
+
this.emit();
|
|
1851
|
+
}
|
|
1122
1852
|
appendSearchQuery(text) {
|
|
1123
|
-
if (!text)
|
|
1853
|
+
if (!text)
|
|
1124
1854
|
return;
|
|
1125
|
-
}
|
|
1126
1855
|
this.searchQuery += text;
|
|
1127
1856
|
this.syncSelectionToSearch();
|
|
1128
1857
|
}
|
|
1129
1858
|
deleteSearchCharacter() {
|
|
1130
|
-
if (!this.searchQuery)
|
|
1859
|
+
if (!this.searchQuery)
|
|
1131
1860
|
return;
|
|
1132
|
-
}
|
|
1133
1861
|
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
1134
1862
|
this.syncSelectionToSearch();
|
|
1135
1863
|
}
|
|
1864
|
+
deleteSearchWord() {
|
|
1865
|
+
if (!this.searchQuery)
|
|
1866
|
+
return;
|
|
1867
|
+
this.searchQuery = this.searchQuery.replace(/\s*\S+\s*$/, "");
|
|
1868
|
+
this.syncSelectionToSearch();
|
|
1869
|
+
}
|
|
1136
1870
|
clearSearch() {
|
|
1137
|
-
if (!this.searchQuery)
|
|
1871
|
+
if (!this.searchQuery)
|
|
1138
1872
|
return;
|
|
1139
|
-
}
|
|
1140
1873
|
this.searchQuery = "";
|
|
1141
1874
|
this.emit();
|
|
1142
1875
|
}
|
|
1143
1876
|
async playSelected() {
|
|
1144
1877
|
const selectedIndex = this.selectedIndex;
|
|
1145
1878
|
await this.enqueueOperation(async () => {
|
|
1879
|
+
if (this.shuffle) {
|
|
1880
|
+
this.resetShuffleOrder(selectedIndex);
|
|
1881
|
+
}
|
|
1146
1882
|
await this.playIndex(selectedIndex);
|
|
1147
1883
|
});
|
|
1148
1884
|
}
|
|
@@ -1259,6 +1995,9 @@ class PlaylistPlayerSession {
|
|
|
1259
1995
|
const nextCurrentIndex = this.playlist.findIndex((playlistTrack) => playlistTrack.hash === currentTrack.hash);
|
|
1260
1996
|
this.currentIndex = nextCurrentIndex === -1 ? null : nextCurrentIndex;
|
|
1261
1997
|
}
|
|
1998
|
+
if (this.shuffle) {
|
|
1999
|
+
this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
|
|
2000
|
+
}
|
|
1262
2001
|
this.emit();
|
|
1263
2002
|
} catch (error) {
|
|
1264
2003
|
this.reportError(error);
|
|
@@ -1304,6 +2043,19 @@ class PlaylistPlayerSession {
|
|
|
1304
2043
|
return null;
|
|
1305
2044
|
}
|
|
1306
2045
|
const baseIndex = this.currentIndex ?? this.selectedIndex;
|
|
2046
|
+
if (this.shuffle) {
|
|
2047
|
+
let orderIndex = this.shuffleOrder.indexOf(baseIndex);
|
|
2048
|
+
if (orderIndex === -1) {
|
|
2049
|
+
this.resetShuffleOrder(baseIndex);
|
|
2050
|
+
orderIndex = 0;
|
|
2051
|
+
}
|
|
2052
|
+
const nextOrderIndex = orderIndex + delta;
|
|
2053
|
+
const shouldWrap2 = wrap || this.loop;
|
|
2054
|
+
if (nextOrderIndex < 0 || nextOrderIndex >= this.shuffleOrder.length) {
|
|
2055
|
+
return shouldWrap2 ? this.shuffleOrder[wrapIndex(nextOrderIndex, this.shuffleOrder.length)] ?? null : null;
|
|
2056
|
+
}
|
|
2057
|
+
return this.shuffleOrder[nextOrderIndex] ?? null;
|
|
2058
|
+
}
|
|
1307
2059
|
const nextIndex = baseIndex + delta;
|
|
1308
2060
|
const shouldWrap = wrap || this.loop;
|
|
1309
2061
|
if (nextIndex < 0) {
|
|
@@ -1314,6 +2066,17 @@ class PlaylistPlayerSession {
|
|
|
1314
2066
|
}
|
|
1315
2067
|
return nextIndex;
|
|
1316
2068
|
}
|
|
2069
|
+
resetShuffleOrder(firstIndex) {
|
|
2070
|
+
const remainingIndices = this.playlist.map((_, index) => index).filter((index) => index !== firstIndex);
|
|
2071
|
+
for (let index = remainingIndices.length - 1;index > 0; index -= 1) {
|
|
2072
|
+
const swapIndex = Math.floor(this.random() * (index + 1));
|
|
2073
|
+
[remainingIndices[index], remainingIndices[swapIndex]] = [
|
|
2074
|
+
remainingIndices[swapIndex],
|
|
2075
|
+
remainingIndices[index]
|
|
2076
|
+
];
|
|
2077
|
+
}
|
|
2078
|
+
this.shuffleOrder = this.playlist[firstIndex] ? [firstIndex, ...remainingIndices] : remainingIndices;
|
|
2079
|
+
}
|
|
1317
2080
|
handleBackendEvent(event) {
|
|
1318
2081
|
switch (event.type) {
|
|
1319
2082
|
case "state":
|
|
@@ -1533,6 +2296,10 @@ class PlaylistPlayerScreen {
|
|
|
1533
2296
|
this.session.toggleLoop();
|
|
1534
2297
|
return;
|
|
1535
2298
|
}
|
|
2299
|
+
if (matchesKey(data, "x")) {
|
|
2300
|
+
this.session.toggleShuffle();
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
1536
2303
|
if (matchesKey(data, "o")) {
|
|
1537
2304
|
this.session.revealSelectedTrack();
|
|
1538
2305
|
return;
|
|
@@ -1570,7 +2337,7 @@ class PlaylistPlayerScreen {
|
|
|
1570
2337
|
} else if (this.searchMode || this.snapshot.searchQuery) {
|
|
1571
2338
|
lines.push(truncateToWidth(style(`/ ${this.snapshot.searchQuery}${this.searchMode ? "▏" : ""}`, CYAN), width));
|
|
1572
2339
|
} else {
|
|
1573
|
-
lines.push(truncateToWidth(style(`${this.snapshot.selectedIndex + 1}/${Math.max(playlist.length, 1)} · loop ${this.snapshot.loop ? "on" : "off"} · ${this.snapshot.backendName}`, DIM), width));
|
|
2340
|
+
lines.push(truncateToWidth(style(`${this.snapshot.selectedIndex + 1}/${Math.max(playlist.length, 1)} · loop ${this.snapshot.loop ? "on" : "off"} · shuffle ${this.snapshot.shuffle ? "on" : "off"} · ${this.snapshot.backendName}`, DIM), width));
|
|
1574
2341
|
}
|
|
1575
2342
|
if (playlist.length === 0) {
|
|
1576
2343
|
lines.push(truncateToWidth("No tracks were found in your osu!lazer library.", width));
|
|
@@ -1596,7 +2363,7 @@ class PlaylistPlayerScreen {
|
|
|
1596
2363
|
while (lines.length < viewportHeight - 1) {
|
|
1597
2364
|
lines.push("");
|
|
1598
2365
|
}
|
|
1599
|
-
lines.push(truncateToWidth(style(this.searchMode ? "type to filter · ↑/↓ results · enter play ·
|
|
2366
|
+
lines.push(truncateToWidth(style(this.searchMode ? "type to filter · ↑/↓ results · enter play · ctrl-w word · esc leave" : "j/k move · ⏎ play · space pause · n/p track · h/l seek · x shuffle · r loop · o reveal · d delete · / search · q quit", DIM), width));
|
|
1600
2367
|
return lines;
|
|
1601
2368
|
}
|
|
1602
2369
|
armPendingGoToTop() {
|
|
@@ -1649,6 +2416,10 @@ class PlaylistPlayerScreen {
|
|
|
1649
2416
|
this.session.deleteSearchCharacter();
|
|
1650
2417
|
return;
|
|
1651
2418
|
}
|
|
2419
|
+
if (matchesKey(data, Key.ctrl("w"))) {
|
|
2420
|
+
this.session.deleteSearchWord();
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
1652
2423
|
if (matchesKey(data, Key.ctrl("u"))) {
|
|
1653
2424
|
this.session.clearSearch();
|
|
1654
2425
|
return;
|
|
@@ -1691,33 +2462,17 @@ class PlaylistPlayerScreen {
|
|
|
1691
2462
|
|
|
1692
2463
|
// src/core/cli/main.ts
|
|
1693
2464
|
init_mod();
|
|
1694
|
-
function formatRealmLoadError(error) {
|
|
1695
|
-
return [
|
|
1696
|
-
"Realm native bindings could not be loaded.",
|
|
1697
|
-
`Current Node runtime: ${process.version}.`,
|
|
1698
|
-
"To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
|
|
1699
|
-
"If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
|
|
1700
|
-
`Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
1701
|
-
].join(`
|
|
1702
|
-
`);
|
|
1703
|
-
}
|
|
1704
|
-
async function loadRealmDependencies() {
|
|
1705
|
-
try {
|
|
1706
|
-
const [{ default: Realm8 }, lazerModule] = await Promise.all([
|
|
1707
|
-
import("realm"),
|
|
1708
|
-
Promise.resolve().then(() => (init_mod3(), exports_mod2))
|
|
1709
|
-
]);
|
|
1710
|
-
return {
|
|
1711
|
-
Realm: Realm8,
|
|
1712
|
-
...lazerModule
|
|
1713
|
-
};
|
|
1714
|
-
} catch (error) {
|
|
1715
|
-
throw new Error(formatRealmLoadError(error), { cause: error });
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
2465
|
function getArgs() {
|
|
1719
|
-
return yargs(hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps
|
|
1720
|
-
Usage: $0 [options]`).option("
|
|
2466
|
+
return yargs(hideBin(process.argv)).scriptName("osu-play").usage(`Play or serve music from your osu!lazer beatmaps
|
|
2467
|
+
Usage: $0 [options]`).option("api", {
|
|
2468
|
+
type: "boolean",
|
|
2469
|
+
default: false,
|
|
2470
|
+
describe: "Run a localhost Subsonic API for music clients such as Kopuz"
|
|
2471
|
+
}).option("apiPort", {
|
|
2472
|
+
type: "number",
|
|
2473
|
+
default: 4533,
|
|
2474
|
+
describe: "Port for --api mode (binds to 127.0.0.1)"
|
|
2475
|
+
}).option("reload", {
|
|
1721
2476
|
type: "boolean",
|
|
1722
2477
|
default: false,
|
|
1723
2478
|
alias: "r",
|
|
@@ -1739,44 +2494,38 @@ Usage: $0 [options]`).option("reload", {
|
|
|
1739
2494
|
default: false,
|
|
1740
2495
|
alias: "l",
|
|
1741
2496
|
describe: "Loop the playlist when playback reaches the end"
|
|
2497
|
+
}).option("shuffle", {
|
|
2498
|
+
type: "boolean",
|
|
2499
|
+
default: false,
|
|
2500
|
+
alias: "s",
|
|
2501
|
+
describe: "Play the playlist in shuffled order"
|
|
2502
|
+
}).check((argv) => {
|
|
2503
|
+
if (!Number.isInteger(argv.apiPort) || argv.apiPort < 1 || argv.apiPort > 65535) {
|
|
2504
|
+
throw new Error("--apiPort must be an integer between 1 and 65535");
|
|
2505
|
+
}
|
|
2506
|
+
const conflictingMode = [
|
|
2507
|
+
argv.exportPlaylist ? "exportPlaylist" : null,
|
|
2508
|
+
argv.loop ? "loop" : null,
|
|
2509
|
+
argv.shuffle ? "shuffle" : null
|
|
2510
|
+
].find(Boolean);
|
|
2511
|
+
if (argv.api && conflictingMode) {
|
|
2512
|
+
throw new Error(`--api cannot be combined with --${conflictingMode}`);
|
|
2513
|
+
}
|
|
2514
|
+
return true;
|
|
1742
2515
|
}).alias("help", "h").help().parse();
|
|
1743
2516
|
}
|
|
1744
|
-
async function createPlaylist(osuDataDir) {
|
|
1745
|
-
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
1746
|
-
if (!realmDBPath) {
|
|
1747
|
-
throw new Error("Realm DB not found");
|
|
1748
|
-
}
|
|
1749
|
-
const {
|
|
1750
|
-
Realm: Realm8,
|
|
1751
|
-
formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
|
|
1752
|
-
getBeatmapSets: getBeatmapSets2,
|
|
1753
|
-
getLazerDB: getLazerDB2,
|
|
1754
|
-
inspectLazerSchema: inspectLazerSchema2
|
|
1755
|
-
} = await loadRealmDependencies();
|
|
1756
|
-
Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
|
|
1757
|
-
const realm = await getLazerDB2(realmDBPath);
|
|
1758
|
-
try {
|
|
1759
|
-
const schemaReport = inspectLazerSchema2(realm);
|
|
1760
|
-
if (!schemaReport.compatible) {
|
|
1761
|
-
throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
|
|
1762
|
-
}
|
|
1763
|
-
return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
|
|
1764
|
-
} finally {
|
|
1765
|
-
realm.close();
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
2517
|
async function deleteTrackFromCollection(track, osuDataDir) {
|
|
1769
|
-
|
|
1770
|
-
await deleteBeatmapSet2(track, osuDataDir);
|
|
2518
|
+
await deleteBeatmapSetFromLazer(track, osuDataDir);
|
|
1771
2519
|
}
|
|
1772
|
-
async function runTuiPlayer(playlist, osuDataDir, loop) {
|
|
2520
|
+
async function runTuiPlayer(playlist, osuDataDir, loop, shuffle) {
|
|
1773
2521
|
const { revealFile: revealFile2 } = await Promise.resolve().then(() => (init_mod(), exports_mod));
|
|
1774
2522
|
const backend = new MpvPlayerBackend;
|
|
1775
2523
|
const session = new PlaylistPlayerSession(playlist, backend, {
|
|
1776
2524
|
deleteTrack: (track) => deleteTrackFromCollection(track, osuDataDir),
|
|
1777
2525
|
loop,
|
|
1778
|
-
reloadPlaylist: () =>
|
|
1779
|
-
revealTrack: (track) => revealFile2(track.path)
|
|
2526
|
+
reloadPlaylist: () => loadPlaylistFromLazer(osuDataDir),
|
|
2527
|
+
revealTrack: (track) => revealFile2(track.path),
|
|
2528
|
+
shuffle
|
|
1780
2529
|
});
|
|
1781
2530
|
const terminal = new ProcessTerminal;
|
|
1782
2531
|
const tui = new TUI(terminal);
|
|
@@ -1807,6 +2556,30 @@ async function runTuiPlayer(playlist, osuDataDir, loop) {
|
|
|
1807
2556
|
await session.dispose();
|
|
1808
2557
|
}
|
|
1809
2558
|
}
|
|
2559
|
+
async function runApiMode(osuDataDir, port) {
|
|
2560
|
+
const catalogStore = await createLazerCatalogStore(osuDataDir);
|
|
2561
|
+
const server = await startApiServer({
|
|
2562
|
+
catalogStore,
|
|
2563
|
+
port
|
|
2564
|
+
});
|
|
2565
|
+
console.log(`[INFO] osu-play API listening at ${server.url}`);
|
|
2566
|
+
console.log("[INFO] In Kopuz, add a Custom server with this URL and any non-empty username/password.");
|
|
2567
|
+
console.log("[INFO] Press Ctrl+C to stop.");
|
|
2568
|
+
let resolveShutdown;
|
|
2569
|
+
const shutdown = new Promise((resolve) => {
|
|
2570
|
+
resolveShutdown = resolve;
|
|
2571
|
+
});
|
|
2572
|
+
const handleSignal = () => resolveShutdown?.();
|
|
2573
|
+
process.once("SIGINT", handleSignal);
|
|
2574
|
+
process.once("SIGTERM", handleSignal);
|
|
2575
|
+
try {
|
|
2576
|
+
await shutdown;
|
|
2577
|
+
} finally {
|
|
2578
|
+
process.off("SIGINT", handleSignal);
|
|
2579
|
+
process.off("SIGTERM", handleSignal);
|
|
2580
|
+
await server.close();
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
1810
2583
|
async function main() {
|
|
1811
2584
|
const argv = await getArgs();
|
|
1812
2585
|
if (argv.reload) {
|
|
@@ -1815,16 +2588,20 @@ async function main() {
|
|
|
1815
2588
|
if (argv.configDir) {
|
|
1816
2589
|
console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
|
|
1817
2590
|
}
|
|
1818
|
-
|
|
2591
|
+
if (argv.api) {
|
|
2592
|
+
await runApiMode(argv.osuDataDir, argv.apiPort);
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
const playlist = await loadPlaylistFromLazer(argv.osuDataDir);
|
|
1819
2596
|
if (argv.exportPlaylist) {
|
|
1820
2597
|
const playlistContents = playlist.map((track) => track.path).join(`
|
|
1821
2598
|
`);
|
|
1822
2599
|
writeFileSync(argv.exportPlaylist, playlistContents);
|
|
1823
2600
|
console.log(`[INFO] Exported ${playlist.length} tracks to ${argv.exportPlaylist}.`);
|
|
1824
|
-
console.log(`[INFO] Use something like \`mpv --playlist=${
|
|
2601
|
+
console.log(`[INFO] Use something like \`mpv --playlist=${path4.resolve(argv.exportPlaylist)}\` to play the playlist.`);
|
|
1825
2602
|
return;
|
|
1826
2603
|
}
|
|
1827
|
-
await runTuiPlayer(playlist, argv.osuDataDir, argv.loop);
|
|
2604
|
+
await runTuiPlayer(playlist, argv.osuDataDir, argv.loop, argv.shuffle);
|
|
1828
2605
|
}
|
|
1829
2606
|
|
|
1830
2607
|
// src/cli.ts
|
|
@@ -1835,4 +2612,4 @@ main().then(() => {
|
|
|
1835
2612
|
process.exit(1);
|
|
1836
2613
|
});
|
|
1837
2614
|
|
|
1838
|
-
//# debugId=
|
|
2615
|
+
//# debugId=7C34DADA116CE55564756E2164756E21
|