osu-play 1.3.2 → 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 +54 -6
- package/dist/cli.cjs +967 -97
- package/dist/cli.cjs.map +10 -7
- package/dist/cli.js +956 -86
- package/dist/cli.js.map +10 -7
- 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/mod.d.ts +1 -1
- package/dist/core/player/session.d.ts +13 -0
- package/dist/core/player/types.d.ts +1 -0
- package/dist/core/utils/mod.d.ts +1 -0
- package/dist/index.cjs +30 -4
- package/dist/index.cjs.map +3 -3
- package/dist/index.js +29 -3
- package/dist/index.js.map +3 -3
- package/package.json +5 -2
package/dist/cli.cjs
CHANGED
|
@@ -42,6 +42,30 @@ var __export = (target, all) => {
|
|
|
42
42
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
43
43
|
|
|
44
44
|
// src/core/utils/mod.ts
|
|
45
|
+
var exports_mod = {};
|
|
46
|
+
__export(exports_mod, {
|
|
47
|
+
revealFile: () => revealFile,
|
|
48
|
+
hashedFilePath: () => hashedFilePath,
|
|
49
|
+
getRealmDBPath: () => getRealmDBPath,
|
|
50
|
+
getDefaultOsuDataDir: () => getDefaultOsuDataDir,
|
|
51
|
+
getDefaultConfigPath: () => getDefaultConfigPath,
|
|
52
|
+
getDataDir: () => getDataDir,
|
|
53
|
+
getConfigDir: () => getConfigDir
|
|
54
|
+
});
|
|
55
|
+
function revealFile(filePath) {
|
|
56
|
+
const command = process.platform === "darwin" ? { args: ["-R", filePath], executable: "open" } : process.platform === "win32" ? { args: ["/select,", filePath], executable: "explorer.exe" } : { args: [import_node_path2.default.dirname(filePath)], executable: "xdg-open" };
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const child = import_node_child_process.spawn(command.executable, command.args, {
|
|
59
|
+
detached: true,
|
|
60
|
+
stdio: "ignore"
|
|
61
|
+
});
|
|
62
|
+
child.once("error", reject);
|
|
63
|
+
child.once("spawn", () => {
|
|
64
|
+
child.unref();
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
45
69
|
function getDataDir() {
|
|
46
70
|
switch (process.platform) {
|
|
47
71
|
case "linux":
|
|
@@ -52,13 +76,13 @@ function getDataDir() {
|
|
|
52
76
|
return xdg;
|
|
53
77
|
const home = process.env.HOME;
|
|
54
78
|
if (home)
|
|
55
|
-
return
|
|
79
|
+
return import_node_path2.default.join(home, ".local", "share");
|
|
56
80
|
break;
|
|
57
81
|
}
|
|
58
82
|
case "darwin": {
|
|
59
83
|
const home = process.env.HOME;
|
|
60
84
|
if (home)
|
|
61
|
-
return
|
|
85
|
+
return import_node_path2.default.join(home, "Library", "Application Support");
|
|
62
86
|
break;
|
|
63
87
|
}
|
|
64
88
|
case "win32":
|
|
@@ -76,13 +100,13 @@ function getConfigDir() {
|
|
|
76
100
|
return xdg;
|
|
77
101
|
const home = process.env.HOME;
|
|
78
102
|
if (home)
|
|
79
|
-
return
|
|
103
|
+
return import_node_path2.default.join(home, ".config");
|
|
80
104
|
break;
|
|
81
105
|
}
|
|
82
106
|
case "darwin": {
|
|
83
107
|
const home = process.env.HOME;
|
|
84
108
|
if (home)
|
|
85
|
-
return
|
|
109
|
+
return import_node_path2.default.join(home, "Library", "Preferences");
|
|
86
110
|
break;
|
|
87
111
|
}
|
|
88
112
|
case "win32":
|
|
@@ -91,28 +115,29 @@ function getConfigDir() {
|
|
|
91
115
|
return;
|
|
92
116
|
}
|
|
93
117
|
function getDefaultOsuDataDir() {
|
|
94
|
-
return
|
|
118
|
+
return import_node_path2.default.join(getDataDir() ?? ".", "osu");
|
|
95
119
|
}
|
|
96
120
|
function getDefaultConfigPath(appName = "osu-play") {
|
|
97
|
-
return
|
|
121
|
+
return import_node_path2.default.join(getConfigDir() ?? ".", appName);
|
|
98
122
|
}
|
|
99
123
|
function hashedFilePath(hash, osuDataDir = getDefaultOsuDataDir()) {
|
|
100
|
-
return
|
|
124
|
+
return import_node_path2.default.join(osuDataDir, "files", hash.slice(0, 1), hash.slice(0, 2), hash);
|
|
101
125
|
}
|
|
102
126
|
function getRealmDBPath(legacyAppConfigDirOrOptions = {}, maybeOptions = {}) {
|
|
103
127
|
const options = typeof legacyAppConfigDirOrOptions === "string" ? maybeOptions : legacyAppConfigDirOrOptions;
|
|
104
128
|
const osuDataDir = options.osuDataDir ?? getDefaultOsuDataDir();
|
|
105
|
-
const osuDBPath =
|
|
106
|
-
if (!
|
|
129
|
+
const osuDBPath = import_node_path2.default.join(osuDataDir, "client.realm");
|
|
130
|
+
if (!import_node_fs2.existsSync(osuDBPath)) {
|
|
107
131
|
console.log(`[getRealmDBPath]: ${osuDBPath} not found`);
|
|
108
132
|
return null;
|
|
109
133
|
}
|
|
110
134
|
return osuDBPath;
|
|
111
135
|
}
|
|
112
|
-
var
|
|
136
|
+
var import_node_path2, import_node_fs2, import_node_child_process;
|
|
113
137
|
var init_mod = __esm(() => {
|
|
114
|
-
|
|
115
|
-
|
|
138
|
+
import_node_path2 = __toESM(require("node:path"));
|
|
139
|
+
import_node_fs2 = require("node:fs");
|
|
140
|
+
import_node_child_process = require("node:child_process");
|
|
116
141
|
});
|
|
117
142
|
|
|
118
143
|
// src/core/lazer/compat.ts
|
|
@@ -415,8 +440,8 @@ var init_mod2 = __esm(() => {
|
|
|
415
440
|
});
|
|
416
441
|
|
|
417
442
|
// src/core/lazer/mod.ts
|
|
418
|
-
var
|
|
419
|
-
__export(
|
|
443
|
+
var exports_mod2 = {};
|
|
444
|
+
__export(exports_mod2, {
|
|
420
445
|
inspectLazerSchemaEntries: () => inspectLazerSchemaEntries,
|
|
421
446
|
inspectLazerSchema: () => inspectLazerSchema,
|
|
422
447
|
hashedFilePath: () => hashedFilePath,
|
|
@@ -504,12 +529,615 @@ var init_mod3 = __esm(() => {
|
|
|
504
529
|
});
|
|
505
530
|
|
|
506
531
|
// src/core/cli/main.ts
|
|
507
|
-
var
|
|
508
|
-
var
|
|
532
|
+
var import_node_path4 = __toESM(require("node:path"));
|
|
533
|
+
var import_node_fs4 = require("node:fs");
|
|
509
534
|
var import_pi_tui2 = require("@earendil-works/pi-tui");
|
|
510
535
|
var import_yargs = __toESM(require("yargs/yargs"));
|
|
511
536
|
var import_helpers = require("yargs/helpers");
|
|
512
537
|
|
|
538
|
+
// src/core/api/server.ts
|
|
539
|
+
var import_node_fs = require("node:fs");
|
|
540
|
+
var import_promises = require("node:fs/promises");
|
|
541
|
+
var import_node_http = require("node:http");
|
|
542
|
+
var import_node_path = __toESM(require("node:path"));
|
|
543
|
+
var API_VERSION = "1.16.1";
|
|
544
|
+
var SERVER_VERSION = "1.4.0";
|
|
545
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
546
|
+
var DEFAULT_PORT = 4533;
|
|
547
|
+
function responseBase(status) {
|
|
548
|
+
return {
|
|
549
|
+
openSubsonic: true,
|
|
550
|
+
serverVersion: SERVER_VERSION,
|
|
551
|
+
status,
|
|
552
|
+
type: "osu-play",
|
|
553
|
+
version: API_VERSION
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function sendJson(response, body, statusCode = 200) {
|
|
557
|
+
const encoded = JSON.stringify(body);
|
|
558
|
+
response.writeHead(statusCode, {
|
|
559
|
+
"Cache-Control": "no-store",
|
|
560
|
+
"Content-Length": Buffer.byteLength(encoded),
|
|
561
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
562
|
+
});
|
|
563
|
+
response.end(encoded);
|
|
564
|
+
}
|
|
565
|
+
function sendSuccess(response, data = {}) {
|
|
566
|
+
sendJson(response, {
|
|
567
|
+
"subsonic-response": {
|
|
568
|
+
...responseBase("ok"),
|
|
569
|
+
...data
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
function sendFailure(response, error, statusCode = 200) {
|
|
574
|
+
sendJson(response, {
|
|
575
|
+
"subsonic-response": {
|
|
576
|
+
...responseBase("failed"),
|
|
577
|
+
error
|
|
578
|
+
}
|
|
579
|
+
}, statusCode);
|
|
580
|
+
}
|
|
581
|
+
function requiredParameter(response, params, name) {
|
|
582
|
+
const value = params.get(name)?.trim();
|
|
583
|
+
if (!value) {
|
|
584
|
+
sendFailure(response, {
|
|
585
|
+
code: 10,
|
|
586
|
+
message: `Required parameter '${name}' is missing.`
|
|
587
|
+
});
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
return value;
|
|
591
|
+
}
|
|
592
|
+
function hasValidCredentials(params) {
|
|
593
|
+
const username = params.get("u")?.trim();
|
|
594
|
+
const token = params.get("t")?.trim();
|
|
595
|
+
const salt = params.get("s")?.trim();
|
|
596
|
+
return Boolean(username && token && salt && /^[a-f0-9]{32}$/i.test(token) && params.get("f") === "json" && params.get("c")?.trim() && params.get("v")?.trim());
|
|
597
|
+
}
|
|
598
|
+
function parseNonNegativeInteger(value, fallback, maximum) {
|
|
599
|
+
if (value === null) {
|
|
600
|
+
return fallback;
|
|
601
|
+
}
|
|
602
|
+
if (!/^\d+$/.test(value)) {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
const parsed = Number(value);
|
|
606
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
return maximum === undefined ? parsed : Math.min(parsed, maximum);
|
|
610
|
+
}
|
|
611
|
+
function albumPayload(album) {
|
|
612
|
+
return {
|
|
613
|
+
artist: album.artist,
|
|
614
|
+
...album.coverArt ? { coverArt: album.coverArt } : {},
|
|
615
|
+
id: album.id,
|
|
616
|
+
name: album.name
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function songPayload(song) {
|
|
620
|
+
return {
|
|
621
|
+
album: song.album,
|
|
622
|
+
albumId: song.albumId,
|
|
623
|
+
artist: song.artist,
|
|
624
|
+
...song.coverArt ? { coverArt: song.coverArt } : {},
|
|
625
|
+
...song.duration ? { duration: song.duration } : {},
|
|
626
|
+
id: song.id,
|
|
627
|
+
title: song.title,
|
|
628
|
+
track: song.track
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function artistIndex(catalog) {
|
|
632
|
+
const groups = new Map;
|
|
633
|
+
for (const artist of catalog.artists) {
|
|
634
|
+
const firstCharacter = artist.name.trim().charAt(0).toUpperCase();
|
|
635
|
+
const name = /^[A-Z]$/.test(firstCharacter) ? firstCharacter : "#";
|
|
636
|
+
const group = groups.get(name) ?? [];
|
|
637
|
+
group.push({
|
|
638
|
+
...artist.coverArt ? { coverArt: artist.coverArt } : {},
|
|
639
|
+
id: artist.id,
|
|
640
|
+
name: artist.name
|
|
641
|
+
});
|
|
642
|
+
groups.set(name, group);
|
|
643
|
+
}
|
|
644
|
+
return [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, artist]) => ({ artist, name }));
|
|
645
|
+
}
|
|
646
|
+
function contentType(fileName) {
|
|
647
|
+
switch (import_node_path.default.extname(fileName).toLowerCase()) {
|
|
648
|
+
case ".aac":
|
|
649
|
+
return "audio/aac";
|
|
650
|
+
case ".flac":
|
|
651
|
+
return "audio/flac";
|
|
652
|
+
case ".jpeg":
|
|
653
|
+
case ".jpg":
|
|
654
|
+
return "image/jpeg";
|
|
655
|
+
case ".m4a":
|
|
656
|
+
case ".mp4":
|
|
657
|
+
return "audio/mp4";
|
|
658
|
+
case ".mp3":
|
|
659
|
+
return "audio/mpeg";
|
|
660
|
+
case ".ogg":
|
|
661
|
+
case ".oga":
|
|
662
|
+
case ".opus":
|
|
663
|
+
return "audio/ogg";
|
|
664
|
+
case ".png":
|
|
665
|
+
return "image/png";
|
|
666
|
+
case ".wav":
|
|
667
|
+
return "audio/wav";
|
|
668
|
+
case ".webm":
|
|
669
|
+
return "audio/webm";
|
|
670
|
+
case ".webp":
|
|
671
|
+
return "image/webp";
|
|
672
|
+
default:
|
|
673
|
+
return "application/octet-stream";
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
function parseRange(rangeHeader, size) {
|
|
677
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
|
678
|
+
if (!match || !match[1] && !match[2]) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
if (!match[1]) {
|
|
682
|
+
const suffixLength = Number(match[2]);
|
|
683
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
end: size - 1,
|
|
688
|
+
start: Math.max(0, size - suffixLength)
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
const start = Number(match[1]);
|
|
692
|
+
const requestedEnd = match[2] ? Number(match[2]) : size - 1;
|
|
693
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start < 0 || requestedEnd < start || start >= size) {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
end: Math.min(requestedEnd, size - 1),
|
|
698
|
+
start
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
async function serveMedia(request, response, media, etag) {
|
|
702
|
+
let file;
|
|
703
|
+
try {
|
|
704
|
+
file = await import_promises.stat(media.path);
|
|
705
|
+
} catch {
|
|
706
|
+
sendFailure(response, {
|
|
707
|
+
code: 70,
|
|
708
|
+
message: "Requested media is no longer available."
|
|
709
|
+
}, 404);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (!file.isFile()) {
|
|
713
|
+
sendFailure(response, {
|
|
714
|
+
code: 70,
|
|
715
|
+
message: "Requested media is not a file."
|
|
716
|
+
}, 404);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const commonHeaders = {
|
|
720
|
+
"Accept-Ranges": "bytes",
|
|
721
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
722
|
+
"Content-Type": contentType(media.fileName),
|
|
723
|
+
ETag: `"${etag}"`
|
|
724
|
+
};
|
|
725
|
+
const rangeHeader = request.headers.range;
|
|
726
|
+
const range = rangeHeader ? parseRange(rangeHeader, file.size) : undefined;
|
|
727
|
+
if (rangeHeader && !range) {
|
|
728
|
+
response.writeHead(416, {
|
|
729
|
+
...commonHeaders,
|
|
730
|
+
"Content-Range": `bytes */${file.size}`
|
|
731
|
+
});
|
|
732
|
+
response.end();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
const start = range?.start ?? 0;
|
|
736
|
+
const end = range?.end ?? file.size - 1;
|
|
737
|
+
const contentLength = Math.max(0, end - start + 1);
|
|
738
|
+
response.writeHead(range ? 206 : 200, {
|
|
739
|
+
...commonHeaders,
|
|
740
|
+
"Content-Length": contentLength,
|
|
741
|
+
...range ? { "Content-Range": `bytes ${start}-${end}/${file.size}` } : {}
|
|
742
|
+
});
|
|
743
|
+
if (request.method === "HEAD" || file.size === 0) {
|
|
744
|
+
response.end();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const stream = import_node_fs.createReadStream(media.path, { end, start });
|
|
748
|
+
const destroyStream = () => stream.destroy();
|
|
749
|
+
response.once("close", destroyStream);
|
|
750
|
+
stream.once("error", (error) => {
|
|
751
|
+
response.destroy(error);
|
|
752
|
+
});
|
|
753
|
+
stream.once("close", () => {
|
|
754
|
+
response.off("close", destroyStream);
|
|
755
|
+
});
|
|
756
|
+
stream.pipe(response);
|
|
757
|
+
}
|
|
758
|
+
async function routeRequest(request, response, catalogStore) {
|
|
759
|
+
const requestUrl = new URL(request.url ?? "/", `http://${DEFAULT_HOST}`);
|
|
760
|
+
const match = /^\/rest\/([A-Za-z0-9]+)\.view$/.exec(requestUrl.pathname);
|
|
761
|
+
if (!match) {
|
|
762
|
+
sendFailure(response, {
|
|
763
|
+
code: 0,
|
|
764
|
+
message: "Unsupported API endpoint."
|
|
765
|
+
}, 404);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const endpoint = match[1];
|
|
769
|
+
const isMedia = endpoint === "stream" || endpoint === "getCoverArt";
|
|
770
|
+
const allowedMethod = request.method === "GET" || isMedia && request.method === "HEAD";
|
|
771
|
+
if (!allowedMethod) {
|
|
772
|
+
response.setHeader("Allow", isMedia ? "GET, HEAD" : "GET");
|
|
773
|
+
sendFailure(response, {
|
|
774
|
+
code: 0,
|
|
775
|
+
message: "Unsupported HTTP method."
|
|
776
|
+
}, 405);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (!hasValidCredentials(requestUrl.searchParams)) {
|
|
780
|
+
sendFailure(response, {
|
|
781
|
+
code: 40,
|
|
782
|
+
message: "Wrong username or password."
|
|
783
|
+
});
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
if (endpoint === "getArtists" || endpoint === "getAlbumList2") {
|
|
787
|
+
const offset = endpoint === "getAlbumList2" ? parseNonNegativeInteger(requestUrl.searchParams.get("offset"), 0) : 0;
|
|
788
|
+
if (offset === 0) {
|
|
789
|
+
await catalogStore.refreshIfChanged();
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const catalog = catalogStore.getCatalog();
|
|
793
|
+
switch (endpoint) {
|
|
794
|
+
case "ping":
|
|
795
|
+
sendSuccess(response);
|
|
796
|
+
return;
|
|
797
|
+
case "getArtists":
|
|
798
|
+
sendSuccess(response, {
|
|
799
|
+
artists: {
|
|
800
|
+
index: artistIndex(catalog)
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
case "getAlbumList2": {
|
|
805
|
+
if (requestUrl.searchParams.get("type") !== "alphabeticalByName") {
|
|
806
|
+
sendFailure(response, {
|
|
807
|
+
code: 10,
|
|
808
|
+
message: "Only type=alphabeticalByName is supported."
|
|
809
|
+
});
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const offset = parseNonNegativeInteger(requestUrl.searchParams.get("offset"), 0);
|
|
813
|
+
const size = parseNonNegativeInteger(requestUrl.searchParams.get("size"), 10, 500);
|
|
814
|
+
if (offset === null || size === null) {
|
|
815
|
+
sendFailure(response, {
|
|
816
|
+
code: 10,
|
|
817
|
+
message: "Album offset and size must be non-negative integers."
|
|
818
|
+
});
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
sendSuccess(response, {
|
|
822
|
+
albumList2: {
|
|
823
|
+
album: catalog.albums.slice(offset, offset + size).map(albumPayload)
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
case "getAlbum": {
|
|
829
|
+
const id = requiredParameter(response, requestUrl.searchParams, "id");
|
|
830
|
+
if (!id)
|
|
831
|
+
return;
|
|
832
|
+
const album = catalog.albumsById.get(id);
|
|
833
|
+
if (!album) {
|
|
834
|
+
sendFailure(response, {
|
|
835
|
+
code: 70,
|
|
836
|
+
message: "Album not found."
|
|
837
|
+
});
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
sendSuccess(response, {
|
|
841
|
+
album: {
|
|
842
|
+
...albumPayload(album),
|
|
843
|
+
song: album.songs.map(songPayload)
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
case "stream": {
|
|
849
|
+
const id = requiredParameter(response, requestUrl.searchParams, "id");
|
|
850
|
+
if (!id)
|
|
851
|
+
return;
|
|
852
|
+
const song = catalog.songsById.get(id);
|
|
853
|
+
if (!song) {
|
|
854
|
+
sendFailure(response, {
|
|
855
|
+
code: 70,
|
|
856
|
+
message: "Song not found."
|
|
857
|
+
}, 404);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
await serveMedia(request, response, {
|
|
861
|
+
fileName: song.fileName,
|
|
862
|
+
path: song.path
|
|
863
|
+
}, song.id);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
case "getCoverArt": {
|
|
867
|
+
const id = requiredParameter(response, requestUrl.searchParams, "id");
|
|
868
|
+
if (!id)
|
|
869
|
+
return;
|
|
870
|
+
const cover = catalog.coversById.get(id);
|
|
871
|
+
if (!cover) {
|
|
872
|
+
sendFailure(response, {
|
|
873
|
+
code: 70,
|
|
874
|
+
message: "Cover art not found."
|
|
875
|
+
}, 404);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
await serveMedia(request, response, cover, id);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
case "getPlaylists":
|
|
882
|
+
sendSuccess(response, {
|
|
883
|
+
playlists: {
|
|
884
|
+
playlist: []
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
return;
|
|
888
|
+
case "getStarred2":
|
|
889
|
+
sendSuccess(response, {
|
|
890
|
+
starred2: {
|
|
891
|
+
song: []
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
return;
|
|
895
|
+
case "scrobble": {
|
|
896
|
+
const id = requiredParameter(response, requestUrl.searchParams, "id");
|
|
897
|
+
if (!id)
|
|
898
|
+
return;
|
|
899
|
+
if (!catalog.songsById.has(id)) {
|
|
900
|
+
sendFailure(response, {
|
|
901
|
+
code: 70,
|
|
902
|
+
message: "Song not found."
|
|
903
|
+
});
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
sendSuccess(response);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
case "getLyricsBySongId":
|
|
910
|
+
sendSuccess(response, {
|
|
911
|
+
lyricsList: {
|
|
912
|
+
structuredLyrics: []
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
return;
|
|
916
|
+
case "getLyrics":
|
|
917
|
+
sendSuccess(response, {
|
|
918
|
+
lyrics: {
|
|
919
|
+
value: ""
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
return;
|
|
923
|
+
case "getPlaylist":
|
|
924
|
+
sendFailure(response, {
|
|
925
|
+
code: 70,
|
|
926
|
+
message: "Playlist not found; osu-play API mode is read-only."
|
|
927
|
+
});
|
|
928
|
+
return;
|
|
929
|
+
case "createPlaylist":
|
|
930
|
+
case "updatePlaylist":
|
|
931
|
+
case "star":
|
|
932
|
+
case "unstar":
|
|
933
|
+
sendFailure(response, {
|
|
934
|
+
code: 0,
|
|
935
|
+
message: "This operation is not supported by read-only osu-play API mode."
|
|
936
|
+
});
|
|
937
|
+
return;
|
|
938
|
+
default:
|
|
939
|
+
sendFailure(response, {
|
|
940
|
+
code: 0,
|
|
941
|
+
message: `Endpoint '${endpoint}.view' is not supported.`
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
async function startApiServer(options) {
|
|
946
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
947
|
+
const requestedPort = options.port ?? DEFAULT_PORT;
|
|
948
|
+
const server = import_node_http.createServer((request, response) => {
|
|
949
|
+
routeRequest(request, response, options.catalogStore).catch((error) => {
|
|
950
|
+
if (!response.headersSent) {
|
|
951
|
+
sendFailure(response, {
|
|
952
|
+
code: 0,
|
|
953
|
+
message: "Internal server error."
|
|
954
|
+
}, 500);
|
|
955
|
+
} else {
|
|
956
|
+
response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
server.requestTimeout = 30000;
|
|
961
|
+
server.headersTimeout = 1e4;
|
|
962
|
+
await new Promise((resolve, reject) => {
|
|
963
|
+
const handleError = (error) => {
|
|
964
|
+
server.off("listening", handleListening);
|
|
965
|
+
reject(error);
|
|
966
|
+
};
|
|
967
|
+
const handleListening = () => {
|
|
968
|
+
server.off("error", handleError);
|
|
969
|
+
resolve();
|
|
970
|
+
};
|
|
971
|
+
server.once("error", handleError);
|
|
972
|
+
server.once("listening", handleListening);
|
|
973
|
+
server.listen(requestedPort, host);
|
|
974
|
+
});
|
|
975
|
+
const address = server.address();
|
|
976
|
+
if (!address || typeof address === "string") {
|
|
977
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
978
|
+
throw new Error("Could not determine the API server address.");
|
|
979
|
+
}
|
|
980
|
+
const port = address.port;
|
|
981
|
+
return {
|
|
982
|
+
async close() {
|
|
983
|
+
if (!server.listening) {
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
await new Promise((resolve, reject) => {
|
|
987
|
+
server.close((error) => {
|
|
988
|
+
if (error)
|
|
989
|
+
reject(error);
|
|
990
|
+
else
|
|
991
|
+
resolve();
|
|
992
|
+
});
|
|
993
|
+
server.closeIdleConnections();
|
|
994
|
+
});
|
|
995
|
+
},
|
|
996
|
+
host,
|
|
997
|
+
port,
|
|
998
|
+
url: `http://${host}:${port}`
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// src/core/lazer/library.ts
|
|
1003
|
+
var import_promises2 = require("node:fs/promises");
|
|
1004
|
+
|
|
1005
|
+
// src/core/api/catalog.ts
|
|
1006
|
+
init_mod();
|
|
1007
|
+
var import_node_crypto = require("node:crypto");
|
|
1008
|
+
function stableId(kind, value) {
|
|
1009
|
+
return import_node_crypto.createHash("sha256").update(`${kind}\x00${value}`).digest("hex");
|
|
1010
|
+
}
|
|
1011
|
+
function normalizedParts(parts) {
|
|
1012
|
+
return [...new Set(parts.map((part) => part?.trim()).filter(Boolean))];
|
|
1013
|
+
}
|
|
1014
|
+
function formatMetadataText(primary, unicode, fallback) {
|
|
1015
|
+
return normalizedParts([primary, unicode]).join(" / ") || fallback;
|
|
1016
|
+
}
|
|
1017
|
+
function getNamedFile(fileName, beatmapSet) {
|
|
1018
|
+
if (!fileName) {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
for (const file of beatmapSet.Files) {
|
|
1022
|
+
if (file.Filename === fileName && file.File?.Hash) {
|
|
1023
|
+
return {
|
|
1024
|
+
fileName,
|
|
1025
|
+
hash: file.File.Hash
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
function beatmapSetKey(beatmapSet) {
|
|
1032
|
+
return String(beatmapSet.ID ?? beatmapSet.Hash ?? "");
|
|
1033
|
+
}
|
|
1034
|
+
function compareText(left, right) {
|
|
1035
|
+
return left.localeCompare(right, undefined, {
|
|
1036
|
+
sensitivity: "base",
|
|
1037
|
+
usage: "sort"
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
function buildMusicCatalog(beatmapSets, osuDataDir) {
|
|
1041
|
+
const albums = [];
|
|
1042
|
+
const coversById = new Map;
|
|
1043
|
+
const seenAudioHashes = new Set;
|
|
1044
|
+
const sortedSets = [...beatmapSets].filter((beatmapSet) => !beatmapSet.DeletePending).sort((left, right) => compareText(beatmapSetKey(left), beatmapSetKey(right)));
|
|
1045
|
+
for (const beatmapSet of sortedSets) {
|
|
1046
|
+
const pendingByHash = new Map;
|
|
1047
|
+
let albumCoverArt;
|
|
1048
|
+
for (const beatmap of beatmapSet.Beatmaps) {
|
|
1049
|
+
const metadata = beatmap.Metadata;
|
|
1050
|
+
const audio = getNamedFile(metadata.AudioFile, beatmapSet);
|
|
1051
|
+
if (!audio) {
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
const background = getNamedFile(metadata.BackgroundFile, beatmapSet);
|
|
1055
|
+
if (background) {
|
|
1056
|
+
coversById.set(background.hash, {
|
|
1057
|
+
fileName: background.fileName,
|
|
1058
|
+
path: hashedFilePath(background.hash, osuDataDir)
|
|
1059
|
+
});
|
|
1060
|
+
albumCoverArt ??= background.hash;
|
|
1061
|
+
}
|
|
1062
|
+
const durationMs = Math.max(0, beatmap.Length ?? 0);
|
|
1063
|
+
const existing = pendingByHash.get(audio.hash);
|
|
1064
|
+
if (existing) {
|
|
1065
|
+
existing.durationMs = Math.max(existing.durationMs, durationMs);
|
|
1066
|
+
existing.coverArt ??= background?.hash;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
pendingByHash.set(audio.hash, {
|
|
1070
|
+
artist: formatMetadataText(metadata.Artist, metadata.ArtistUnicode, "Unknown Artist"),
|
|
1071
|
+
coverArt: background?.hash,
|
|
1072
|
+
durationMs,
|
|
1073
|
+
fileName: audio.fileName,
|
|
1074
|
+
hash: audio.hash,
|
|
1075
|
+
title: formatMetadataText(metadata.Title, metadata.TitleUnicode, "Unknown Title")
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
const retained = [...pendingByHash.values()].filter((song) => !seenAudioHashes.has(song.hash));
|
|
1079
|
+
if (retained.length === 0) {
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
for (const song of retained) {
|
|
1083
|
+
seenAudioHashes.add(song.hash);
|
|
1084
|
+
}
|
|
1085
|
+
const firstSong = retained[0];
|
|
1086
|
+
const setKey = beatmapSetKey(beatmapSet) || firstSong.hash;
|
|
1087
|
+
const albumId = stableId("album", setKey);
|
|
1088
|
+
const albumName = firstSong.title;
|
|
1089
|
+
const albumArtist = firstSong.artist;
|
|
1090
|
+
const coverArt = albumCoverArt ?? firstSong.coverArt;
|
|
1091
|
+
const songs = retained.map((song, index) => {
|
|
1092
|
+
const duration = Math.round(song.durationMs / 1000);
|
|
1093
|
+
return {
|
|
1094
|
+
album: albumName,
|
|
1095
|
+
albumId,
|
|
1096
|
+
artist: song.artist,
|
|
1097
|
+
...coverArt ? { coverArt } : {},
|
|
1098
|
+
...duration > 0 ? { duration } : {},
|
|
1099
|
+
fileName: song.fileName,
|
|
1100
|
+
id: song.hash,
|
|
1101
|
+
path: hashedFilePath(song.hash, osuDataDir),
|
|
1102
|
+
title: song.title,
|
|
1103
|
+
track: index + 1
|
|
1104
|
+
};
|
|
1105
|
+
});
|
|
1106
|
+
albums.push({
|
|
1107
|
+
artist: albumArtist,
|
|
1108
|
+
...coverArt ? { coverArt } : {},
|
|
1109
|
+
id: albumId,
|
|
1110
|
+
name: albumName,
|
|
1111
|
+
songs
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
albums.sort((left, right) => compareText(left.name, right.name) || left.id.localeCompare(right.id));
|
|
1115
|
+
const albumsById = new Map(albums.map((album) => [album.id, album]));
|
|
1116
|
+
const songsById = new Map(albums.flatMap((album) => album.songs.map((song) => [song.id, song])));
|
|
1117
|
+
const artistsByName = new Map;
|
|
1118
|
+
for (const album of albums) {
|
|
1119
|
+
const normalizedName = album.artist.trim().toLocaleLowerCase();
|
|
1120
|
+
const existing = artistsByName.get(normalizedName);
|
|
1121
|
+
if (existing) {
|
|
1122
|
+
existing.coverArt ??= album.coverArt;
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
artistsByName.set(normalizedName, {
|
|
1126
|
+
...album.coverArt ? { coverArt: album.coverArt } : {},
|
|
1127
|
+
id: stableId("artist", normalizedName),
|
|
1128
|
+
name: album.artist
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
const artists = [...artistsByName.values()].sort((left, right) => compareText(left.name, right.name) || left.id.localeCompare(right.id));
|
|
1132
|
+
return {
|
|
1133
|
+
albums,
|
|
1134
|
+
albumsById,
|
|
1135
|
+
artists,
|
|
1136
|
+
coversById,
|
|
1137
|
+
songsById
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
513
1141
|
// src/core/playlist/mod.ts
|
|
514
1142
|
init_mod();
|
|
515
1143
|
function uniqueParts(parts) {
|
|
@@ -554,13 +1182,124 @@ function buildPlaylist(beatmapSets, osuDataDir) {
|
|
|
554
1182
|
return playlist;
|
|
555
1183
|
}
|
|
556
1184
|
|
|
1185
|
+
// src/core/lazer/library.ts
|
|
1186
|
+
init_mod();
|
|
1187
|
+
function formatRealmLoadError(error) {
|
|
1188
|
+
return [
|
|
1189
|
+
"Realm native bindings could not be loaded.",
|
|
1190
|
+
`Current Node runtime: ${process.version}.`,
|
|
1191
|
+
"To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
|
|
1192
|
+
"If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
|
|
1193
|
+
`Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
1194
|
+
].join(`
|
|
1195
|
+
`);
|
|
1196
|
+
}
|
|
1197
|
+
async function loadRealmDependencies() {
|
|
1198
|
+
try {
|
|
1199
|
+
const [{ default: Realm8 }, lazerModule] = await Promise.all([
|
|
1200
|
+
import("realm"),
|
|
1201
|
+
Promise.resolve().then(() => (init_mod3(), exports_mod2))
|
|
1202
|
+
]);
|
|
1203
|
+
return {
|
|
1204
|
+
Realm: Realm8,
|
|
1205
|
+
...lazerModule
|
|
1206
|
+
};
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
throw new Error(formatRealmLoadError(error), { cause: error });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
async function projectLazerLibrary(osuDataDir, projector) {
|
|
1212
|
+
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
1213
|
+
if (!realmDBPath) {
|
|
1214
|
+
throw new Error("Realm DB not found");
|
|
1215
|
+
}
|
|
1216
|
+
const {
|
|
1217
|
+
Realm: Realm8,
|
|
1218
|
+
formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
|
|
1219
|
+
getBeatmapSets: getBeatmapSets2,
|
|
1220
|
+
getLazerDB: getLazerDB2,
|
|
1221
|
+
inspectLazerSchema: inspectLazerSchema2
|
|
1222
|
+
} = await loadRealmDependencies();
|
|
1223
|
+
Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
|
|
1224
|
+
const realm = await getLazerDB2(realmDBPath);
|
|
1225
|
+
try {
|
|
1226
|
+
const schemaReport = inspectLazerSchema2(realm);
|
|
1227
|
+
if (!schemaReport.compatible) {
|
|
1228
|
+
throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
|
|
1229
|
+
}
|
|
1230
|
+
return projector(getBeatmapSets2(realm));
|
|
1231
|
+
} finally {
|
|
1232
|
+
realm.close();
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function loadPlaylistFromLazer(osuDataDir) {
|
|
1236
|
+
return projectLazerLibrary(osuDataDir, (beatmapSets) => buildPlaylist(beatmapSets, osuDataDir));
|
|
1237
|
+
}
|
|
1238
|
+
function loadMusicCatalogFromLazer(osuDataDir) {
|
|
1239
|
+
return projectLazerLibrary(osuDataDir, (beatmapSets) => buildMusicCatalog(beatmapSets, osuDataDir));
|
|
1240
|
+
}
|
|
1241
|
+
async function deleteBeatmapSetFromLazer(track, osuDataDir) {
|
|
1242
|
+
const { deleteBeatmapSet: deleteBeatmapSet2 } = await loadRealmDependencies();
|
|
1243
|
+
await deleteBeatmapSet2(track, osuDataDir);
|
|
1244
|
+
}
|
|
1245
|
+
async function createRefreshingCatalogStore(options) {
|
|
1246
|
+
let catalog = await options.loadCatalog();
|
|
1247
|
+
let signature = await options.getSignature();
|
|
1248
|
+
let refreshPromise = null;
|
|
1249
|
+
return {
|
|
1250
|
+
getCatalog() {
|
|
1251
|
+
return catalog;
|
|
1252
|
+
},
|
|
1253
|
+
async refreshIfChanged() {
|
|
1254
|
+
if (refreshPromise) {
|
|
1255
|
+
return refreshPromise;
|
|
1256
|
+
}
|
|
1257
|
+
refreshPromise = (async () => {
|
|
1258
|
+
try {
|
|
1259
|
+
const nextSignature = await options.getSignature();
|
|
1260
|
+
if (nextSignature === signature) {
|
|
1261
|
+
return catalog;
|
|
1262
|
+
}
|
|
1263
|
+
const nextCatalog = await options.loadCatalog();
|
|
1264
|
+
catalog = nextCatalog;
|
|
1265
|
+
signature = nextSignature;
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
options.onRefreshError?.(error);
|
|
1268
|
+
}
|
|
1269
|
+
return catalog;
|
|
1270
|
+
})();
|
|
1271
|
+
try {
|
|
1272
|
+
return await refreshPromise;
|
|
1273
|
+
} finally {
|
|
1274
|
+
refreshPromise = null;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
async function createLazerCatalogStore(osuDataDir, onRefreshError = (error) => {
|
|
1280
|
+
console.warn(`[WARN] Could not refresh the osu!lazer catalog; continuing with the last good snapshot: ${error instanceof Error ? error.message : String(error)}`);
|
|
1281
|
+
}) {
|
|
1282
|
+
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
1283
|
+
if (!realmDBPath) {
|
|
1284
|
+
throw new Error("Realm DB not found");
|
|
1285
|
+
}
|
|
1286
|
+
return createRefreshingCatalogStore({
|
|
1287
|
+
async getSignature() {
|
|
1288
|
+
const file = await import_promises2.stat(realmDBPath);
|
|
1289
|
+
return `${file.mtimeMs}:${file.size}`;
|
|
1290
|
+
},
|
|
1291
|
+
loadCatalog: () => loadMusicCatalogFromLazer(osuDataDir),
|
|
1292
|
+
onRefreshError
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
|
|
557
1296
|
// src/core/player/mpv.ts
|
|
558
|
-
var
|
|
559
|
-
var
|
|
1297
|
+
var import_node_child_process2 = require("node:child_process");
|
|
1298
|
+
var import_node_fs3 = require("node:fs");
|
|
560
1299
|
var import_node_net = __toESM(require("node:net"));
|
|
561
1300
|
var import_node_os = __toESM(require("node:os"));
|
|
562
|
-
var
|
|
563
|
-
var
|
|
1301
|
+
var import_node_path3 = __toESM(require("node:path"));
|
|
1302
|
+
var import_promises3 = require("node:timers/promises");
|
|
564
1303
|
function getErrorCode(error) {
|
|
565
1304
|
if (error && typeof error === "object" && "code" in error) {
|
|
566
1305
|
const { code } = error;
|
|
@@ -581,7 +1320,7 @@ function createSocketPath() {
|
|
|
581
1320
|
if (process.platform === "win32") {
|
|
582
1321
|
return `\\\\.\\pipe\\${id}`;
|
|
583
1322
|
}
|
|
584
|
-
return
|
|
1323
|
+
return import_node_path3.default.join(import_node_os.default.tmpdir(), `${id}.sock`);
|
|
585
1324
|
}
|
|
586
1325
|
function isControlText(value) {
|
|
587
1326
|
return [...value].some((character) => {
|
|
@@ -710,7 +1449,7 @@ class MpvPlayerBackend {
|
|
|
710
1449
|
this.cleanupSocketPath();
|
|
711
1450
|
this.disposing = false;
|
|
712
1451
|
this.stderrBuffer = "";
|
|
713
|
-
const processHandle =
|
|
1452
|
+
const processHandle = import_node_child_process2.spawn("mpv", [
|
|
714
1453
|
"--no-config",
|
|
715
1454
|
"--no-terminal",
|
|
716
1455
|
"--idle=yes",
|
|
@@ -761,7 +1500,7 @@ ${detail}` : "";
|
|
|
761
1500
|
throw error;
|
|
762
1501
|
}
|
|
763
1502
|
}
|
|
764
|
-
await
|
|
1503
|
+
await import_promises3.setTimeout(50);
|
|
765
1504
|
}
|
|
766
1505
|
throw new Error(this.formatStartupError("Timed out waiting for mpv IPC to become ready."));
|
|
767
1506
|
}
|
|
@@ -938,7 +1677,7 @@ ${detail}` : "";
|
|
|
938
1677
|
new Promise((resolve) => {
|
|
939
1678
|
processHandle.once("exit", () => resolve());
|
|
940
1679
|
}),
|
|
941
|
-
|
|
1680
|
+
import_promises3.setTimeout(1000)
|
|
942
1681
|
]);
|
|
943
1682
|
if (processHandle.exitCode === null) {
|
|
944
1683
|
processHandle.kill("SIGKILL");
|
|
@@ -950,8 +1689,8 @@ ${detail}` : "";
|
|
|
950
1689
|
if (process.platform === "win32") {
|
|
951
1690
|
return;
|
|
952
1691
|
}
|
|
953
|
-
if (
|
|
954
|
-
|
|
1692
|
+
if (import_node_fs3.existsSync(this.socketPath)) {
|
|
1693
|
+
import_node_fs3.rmSync(this.socketPath, { force: true });
|
|
955
1694
|
}
|
|
956
1695
|
}
|
|
957
1696
|
formatStartupError(message) {
|
|
@@ -989,6 +1728,13 @@ function findTrackIndexByQuery(playlist, query) {
|
|
|
989
1728
|
}
|
|
990
1729
|
return playlist.findIndex((track) => normalizeSearchText(track.title).includes(normalizedQuery));
|
|
991
1730
|
}
|
|
1731
|
+
function findTrackIndicesByQuery(playlist, query) {
|
|
1732
|
+
const normalizedQuery = normalizeSearchText(query);
|
|
1733
|
+
if (!normalizedQuery) {
|
|
1734
|
+
return playlist.map((_, index) => index);
|
|
1735
|
+
}
|
|
1736
|
+
return playlist.flatMap((track, index) => normalizeSearchText(track.title).includes(normalizedQuery) ? [index] : []);
|
|
1737
|
+
}
|
|
992
1738
|
|
|
993
1739
|
class PlaylistPlayerSession {
|
|
994
1740
|
backend;
|
|
@@ -998,9 +1744,13 @@ class PlaylistPlayerSession {
|
|
|
998
1744
|
loop;
|
|
999
1745
|
deleteTrack;
|
|
1000
1746
|
playlist;
|
|
1747
|
+
random;
|
|
1001
1748
|
reloadPlaylist;
|
|
1749
|
+
revealTrack;
|
|
1002
1750
|
searchQuery = "";
|
|
1003
1751
|
selectedIndex = 0;
|
|
1752
|
+
shuffle;
|
|
1753
|
+
shuffleOrder = [];
|
|
1004
1754
|
operationQueue = Promise.resolve();
|
|
1005
1755
|
unsubscribeBackend;
|
|
1006
1756
|
constructor(playlist, backend, options = {}) {
|
|
@@ -1008,7 +1758,13 @@ class PlaylistPlayerSession {
|
|
|
1008
1758
|
this.playlist = playlist;
|
|
1009
1759
|
this.deleteTrack = options.deleteTrack;
|
|
1010
1760
|
this.loop = options.loop ?? false;
|
|
1761
|
+
this.random = options.random ?? Math.random;
|
|
1011
1762
|
this.reloadPlaylist = options.reloadPlaylist;
|
|
1763
|
+
this.revealTrack = options.revealTrack;
|
|
1764
|
+
this.shuffle = options.shuffle ?? false;
|
|
1765
|
+
if (this.shuffle) {
|
|
1766
|
+
this.resetShuffleOrder(this.selectedIndex);
|
|
1767
|
+
}
|
|
1012
1768
|
this.unsubscribeBackend = backend.subscribe((event) => {
|
|
1013
1769
|
this.handleBackendEvent(event);
|
|
1014
1770
|
});
|
|
@@ -1026,6 +1782,7 @@ class PlaylistPlayerSession {
|
|
|
1026
1782
|
playlist: this.playlist,
|
|
1027
1783
|
searchQuery: this.searchQuery,
|
|
1028
1784
|
selectedIndex: this.selectedIndex,
|
|
1785
|
+
shuffle: this.shuffle,
|
|
1029
1786
|
status: backendSnapshot.status,
|
|
1030
1787
|
timePositionSeconds: backendSnapshot.timePositionSeconds
|
|
1031
1788
|
};
|
|
@@ -1049,24 +1806,44 @@ class PlaylistPlayerSession {
|
|
|
1049
1806
|
if (this.playlist.length === 0) {
|
|
1050
1807
|
return;
|
|
1051
1808
|
}
|
|
1809
|
+
if (this.searchQuery) {
|
|
1810
|
+
this.moveSearchSelection(delta);
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1052
1813
|
this.selectedIndex = wrapIndex(this.selectedIndex + delta, this.playlist.length);
|
|
1053
1814
|
this.emit();
|
|
1054
1815
|
}
|
|
1055
1816
|
moveSelectionPage(delta, pageSize) {
|
|
1817
|
+
if (this.searchQuery) {
|
|
1818
|
+
this.moveSearchSelection(delta * Math.max(1, pageSize));
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1056
1821
|
this.moveSelection(delta * Math.max(1, pageSize));
|
|
1057
1822
|
}
|
|
1823
|
+
moveSearchSelection(delta) {
|
|
1824
|
+
const matches = findTrackIndicesByQuery(this.playlist, this.searchQuery);
|
|
1825
|
+
if (matches.length === 0) {
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
const currentMatchIndex = matches.indexOf(this.selectedIndex);
|
|
1829
|
+
const nextMatchIndex = wrapIndex(currentMatchIndex + delta, matches.length);
|
|
1830
|
+
this.selectedIndex = matches[nextMatchIndex] ?? this.selectedIndex;
|
|
1831
|
+
this.emit();
|
|
1832
|
+
}
|
|
1058
1833
|
selectHome() {
|
|
1059
1834
|
if (this.playlist.length === 0) {
|
|
1060
1835
|
return;
|
|
1061
1836
|
}
|
|
1062
|
-
this.
|
|
1837
|
+
const matches = findTrackIndicesByQuery(this.playlist, this.searchQuery);
|
|
1838
|
+
this.selectedIndex = matches[0] ?? 0;
|
|
1063
1839
|
this.emit();
|
|
1064
1840
|
}
|
|
1065
1841
|
selectEnd() {
|
|
1066
1842
|
if (this.playlist.length === 0) {
|
|
1067
1843
|
return;
|
|
1068
1844
|
}
|
|
1069
|
-
|
|
1845
|
+
const matches = findTrackIndicesByQuery(this.playlist, this.searchQuery);
|
|
1846
|
+
this.selectedIndex = matches.at(-1) ?? this.playlist.length - 1;
|
|
1070
1847
|
this.emit();
|
|
1071
1848
|
}
|
|
1072
1849
|
setSelectionIndex(index) {
|
|
@@ -1080,30 +1857,43 @@ class PlaylistPlayerSession {
|
|
|
1080
1857
|
this.loop = !this.loop;
|
|
1081
1858
|
this.emit();
|
|
1082
1859
|
}
|
|
1860
|
+
toggleShuffle() {
|
|
1861
|
+
this.shuffle = !this.shuffle;
|
|
1862
|
+
if (this.shuffle) {
|
|
1863
|
+
this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
|
|
1864
|
+
}
|
|
1865
|
+
this.emit();
|
|
1866
|
+
}
|
|
1083
1867
|
appendSearchQuery(text) {
|
|
1084
|
-
if (!text)
|
|
1868
|
+
if (!text)
|
|
1085
1869
|
return;
|
|
1086
|
-
}
|
|
1087
1870
|
this.searchQuery += text;
|
|
1088
1871
|
this.syncSelectionToSearch();
|
|
1089
1872
|
}
|
|
1090
1873
|
deleteSearchCharacter() {
|
|
1091
|
-
if (!this.searchQuery)
|
|
1874
|
+
if (!this.searchQuery)
|
|
1092
1875
|
return;
|
|
1093
|
-
}
|
|
1094
1876
|
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
1095
1877
|
this.syncSelectionToSearch();
|
|
1096
1878
|
}
|
|
1879
|
+
deleteSearchWord() {
|
|
1880
|
+
if (!this.searchQuery)
|
|
1881
|
+
return;
|
|
1882
|
+
this.searchQuery = this.searchQuery.replace(/\s*\S+\s*$/, "");
|
|
1883
|
+
this.syncSelectionToSearch();
|
|
1884
|
+
}
|
|
1097
1885
|
clearSearch() {
|
|
1098
|
-
if (!this.searchQuery)
|
|
1886
|
+
if (!this.searchQuery)
|
|
1099
1887
|
return;
|
|
1100
|
-
}
|
|
1101
1888
|
this.searchQuery = "";
|
|
1102
1889
|
this.emit();
|
|
1103
1890
|
}
|
|
1104
1891
|
async playSelected() {
|
|
1105
1892
|
const selectedIndex = this.selectedIndex;
|
|
1106
1893
|
await this.enqueueOperation(async () => {
|
|
1894
|
+
if (this.shuffle) {
|
|
1895
|
+
this.resetShuffleOrder(selectedIndex);
|
|
1896
|
+
}
|
|
1107
1897
|
await this.playIndex(selectedIndex);
|
|
1108
1898
|
});
|
|
1109
1899
|
}
|
|
@@ -1172,6 +1962,22 @@ class PlaylistPlayerSession {
|
|
|
1172
1962
|
}
|
|
1173
1963
|
});
|
|
1174
1964
|
}
|
|
1965
|
+
async revealSelectedTrack() {
|
|
1966
|
+
const track = this.playlist[this.selectedIndex];
|
|
1967
|
+
if (!track) {
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (!this.revealTrack) {
|
|
1971
|
+
this.reportError(new Error("Opening the containing folder is unavailable."));
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
try {
|
|
1975
|
+
this.clearError();
|
|
1976
|
+
await this.revealTrack(track);
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
this.reportError(error);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1175
1981
|
async deleteSelectedTrack() {
|
|
1176
1982
|
const selectedIndex = this.selectedIndex;
|
|
1177
1983
|
await this.enqueueOperation(async () => {
|
|
@@ -1204,6 +2010,9 @@ class PlaylistPlayerSession {
|
|
|
1204
2010
|
const nextCurrentIndex = this.playlist.findIndex((playlistTrack) => playlistTrack.hash === currentTrack.hash);
|
|
1205
2011
|
this.currentIndex = nextCurrentIndex === -1 ? null : nextCurrentIndex;
|
|
1206
2012
|
}
|
|
2013
|
+
if (this.shuffle) {
|
|
2014
|
+
this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
|
|
2015
|
+
}
|
|
1207
2016
|
this.emit();
|
|
1208
2017
|
} catch (error) {
|
|
1209
2018
|
this.reportError(error);
|
|
@@ -1249,6 +2058,19 @@ class PlaylistPlayerSession {
|
|
|
1249
2058
|
return null;
|
|
1250
2059
|
}
|
|
1251
2060
|
const baseIndex = this.currentIndex ?? this.selectedIndex;
|
|
2061
|
+
if (this.shuffle) {
|
|
2062
|
+
let orderIndex = this.shuffleOrder.indexOf(baseIndex);
|
|
2063
|
+
if (orderIndex === -1) {
|
|
2064
|
+
this.resetShuffleOrder(baseIndex);
|
|
2065
|
+
orderIndex = 0;
|
|
2066
|
+
}
|
|
2067
|
+
const nextOrderIndex = orderIndex + delta;
|
|
2068
|
+
const shouldWrap2 = wrap || this.loop;
|
|
2069
|
+
if (nextOrderIndex < 0 || nextOrderIndex >= this.shuffleOrder.length) {
|
|
2070
|
+
return shouldWrap2 ? this.shuffleOrder[wrapIndex(nextOrderIndex, this.shuffleOrder.length)] ?? null : null;
|
|
2071
|
+
}
|
|
2072
|
+
return this.shuffleOrder[nextOrderIndex] ?? null;
|
|
2073
|
+
}
|
|
1252
2074
|
const nextIndex = baseIndex + delta;
|
|
1253
2075
|
const shouldWrap = wrap || this.loop;
|
|
1254
2076
|
if (nextIndex < 0) {
|
|
@@ -1259,6 +2081,17 @@ class PlaylistPlayerSession {
|
|
|
1259
2081
|
}
|
|
1260
2082
|
return nextIndex;
|
|
1261
2083
|
}
|
|
2084
|
+
resetShuffleOrder(firstIndex) {
|
|
2085
|
+
const remainingIndices = this.playlist.map((_, index) => index).filter((index) => index !== firstIndex);
|
|
2086
|
+
for (let index = remainingIndices.length - 1;index > 0; index -= 1) {
|
|
2087
|
+
const swapIndex = Math.floor(this.random() * (index + 1));
|
|
2088
|
+
[remainingIndices[index], remainingIndices[swapIndex]] = [
|
|
2089
|
+
remainingIndices[swapIndex],
|
|
2090
|
+
remainingIndices[index]
|
|
2091
|
+
];
|
|
2092
|
+
}
|
|
2093
|
+
this.shuffleOrder = this.playlist[firstIndex] ? [firstIndex, ...remainingIndices] : remainingIndices;
|
|
2094
|
+
}
|
|
1262
2095
|
handleBackendEvent(event) {
|
|
1263
2096
|
switch (event.type) {
|
|
1264
2097
|
case "state":
|
|
@@ -1473,6 +2306,14 @@ class PlaylistPlayerScreen {
|
|
|
1473
2306
|
this.session.toggleLoop();
|
|
1474
2307
|
return;
|
|
1475
2308
|
}
|
|
2309
|
+
if (import_pi_tui.matchesKey(data, "x")) {
|
|
2310
|
+
this.session.toggleShuffle();
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
if (import_pi_tui.matchesKey(data, "o")) {
|
|
2314
|
+
this.session.revealSelectedTrack();
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
1476
2317
|
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.backspace)) {
|
|
1477
2318
|
this.session.deleteSearchCharacter();
|
|
1478
2319
|
return;
|
|
@@ -1484,9 +2325,11 @@ class PlaylistPlayerScreen {
|
|
|
1484
2325
|
}
|
|
1485
2326
|
render(width) {
|
|
1486
2327
|
const { playlist } = this.snapshot;
|
|
2328
|
+
const visibleIndices = this.snapshot.searchQuery ? findTrackIndicesByQuery(playlist, this.snapshot.searchQuery) : playlist.map((_, index) => index);
|
|
2329
|
+
const selectedVisibleIndex = Math.max(0, visibleIndices.indexOf(this.snapshot.selectedIndex));
|
|
1487
2330
|
const viewportHeight = Math.max(this.getViewportHeight(), RESERVED_ROWS);
|
|
1488
2331
|
const listHeight = this.getListHeight(viewportHeight);
|
|
1489
|
-
const { start, end } = getVisibleTrackRange(
|
|
2332
|
+
const { start, end } = getVisibleTrackRange(selectedVisibleIndex, visibleIndices.length, listHeight);
|
|
1490
2333
|
const position = formatSeconds(this.snapshot.timePositionSeconds);
|
|
1491
2334
|
const duration = formatSeconds(this.snapshot.durationSeconds);
|
|
1492
2335
|
const glyph = STATUS_GLYPH[this.snapshot.status] ?? "·";
|
|
@@ -1504,12 +2347,18 @@ class PlaylistPlayerScreen {
|
|
|
1504
2347
|
} else if (this.searchMode || this.snapshot.searchQuery) {
|
|
1505
2348
|
lines.push(import_pi_tui.truncateToWidth(style(`/ ${this.snapshot.searchQuery}${this.searchMode ? "▏" : ""}`, CYAN), width));
|
|
1506
2349
|
} else {
|
|
1507
|
-
lines.push(import_pi_tui.truncateToWidth(style(`${this.snapshot.selectedIndex + 1}/${Math.max(playlist.length, 1)} · loop ${this.snapshot.loop ? "on" : "off"} · ${this.snapshot.backendName}`, DIM), width));
|
|
2350
|
+
lines.push(import_pi_tui.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));
|
|
1508
2351
|
}
|
|
1509
2352
|
if (playlist.length === 0) {
|
|
1510
2353
|
lines.push(import_pi_tui.truncateToWidth("No tracks were found in your osu!lazer library.", width));
|
|
2354
|
+
} else if (visibleIndices.length === 0) {
|
|
2355
|
+
lines.push(import_pi_tui.truncateToWidth("No tracks match the current search.", width));
|
|
1511
2356
|
} else {
|
|
1512
|
-
for (let
|
|
2357
|
+
for (let visibleIndex = start;visibleIndex < end; visibleIndex += 1) {
|
|
2358
|
+
const index = visibleIndices[visibleIndex];
|
|
2359
|
+
if (index === undefined) {
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
1513
2362
|
const track = playlist[index];
|
|
1514
2363
|
if (!track) {
|
|
1515
2364
|
continue;
|
|
@@ -1524,7 +2373,7 @@ class PlaylistPlayerScreen {
|
|
|
1524
2373
|
while (lines.length < viewportHeight - 1) {
|
|
1525
2374
|
lines.push("");
|
|
1526
2375
|
}
|
|
1527
|
-
lines.push(import_pi_tui.truncateToWidth(style(this.searchMode ? "type to
|
|
2376
|
+
lines.push(import_pi_tui.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));
|
|
1528
2377
|
return lines;
|
|
1529
2378
|
}
|
|
1530
2379
|
armPendingGoToTop() {
|
|
@@ -1566,6 +2415,7 @@ class PlaylistPlayerScreen {
|
|
|
1566
2415
|
}
|
|
1567
2416
|
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.enter)) {
|
|
1568
2417
|
this.searchMode = false;
|
|
2418
|
+
this.session.playSelected();
|
|
1569
2419
|
return;
|
|
1570
2420
|
}
|
|
1571
2421
|
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.escape)) {
|
|
@@ -1576,10 +2426,22 @@ class PlaylistPlayerScreen {
|
|
|
1576
2426
|
this.session.deleteSearchCharacter();
|
|
1577
2427
|
return;
|
|
1578
2428
|
}
|
|
2429
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("w"))) {
|
|
2430
|
+
this.session.deleteSearchWord();
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
1579
2433
|
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("u"))) {
|
|
1580
2434
|
this.session.clearSearch();
|
|
1581
2435
|
return;
|
|
1582
2436
|
}
|
|
2437
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.up) || import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("p"))) {
|
|
2438
|
+
this.session.moveSearchSelection(-1);
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.down) || import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("n"))) {
|
|
2442
|
+
this.session.moveSearchSelection(1);
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
1583
2445
|
const printable = decodePrintableText(data);
|
|
1584
2446
|
if (!printable) {
|
|
1585
2447
|
return;
|
|
@@ -1610,33 +2472,17 @@ class PlaylistPlayerScreen {
|
|
|
1610
2472
|
|
|
1611
2473
|
// src/core/cli/main.ts
|
|
1612
2474
|
init_mod();
|
|
1613
|
-
function formatRealmLoadError(error) {
|
|
1614
|
-
return [
|
|
1615
|
-
"Realm native bindings could not be loaded.",
|
|
1616
|
-
`Current Node runtime: ${process.version}.`,
|
|
1617
|
-
"To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
|
|
1618
|
-
"If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
|
|
1619
|
-
`Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
1620
|
-
].join(`
|
|
1621
|
-
`);
|
|
1622
|
-
}
|
|
1623
|
-
async function loadRealmDependencies() {
|
|
1624
|
-
try {
|
|
1625
|
-
const [{ default: Realm8 }, lazerModule] = await Promise.all([
|
|
1626
|
-
import("realm"),
|
|
1627
|
-
Promise.resolve().then(() => (init_mod3(), exports_mod))
|
|
1628
|
-
]);
|
|
1629
|
-
return {
|
|
1630
|
-
Realm: Realm8,
|
|
1631
|
-
...lazerModule
|
|
1632
|
-
};
|
|
1633
|
-
} catch (error) {
|
|
1634
|
-
throw new Error(formatRealmLoadError(error), { cause: error });
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1637
2475
|
function getArgs() {
|
|
1638
|
-
return import_yargs.default(import_helpers.hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps
|
|
1639
|
-
Usage: $0 [options]`).option("
|
|
2476
|
+
return import_yargs.default(import_helpers.hideBin(process.argv)).scriptName("osu-play").usage(`Play or serve music from your osu!lazer beatmaps
|
|
2477
|
+
Usage: $0 [options]`).option("api", {
|
|
2478
|
+
type: "boolean",
|
|
2479
|
+
default: false,
|
|
2480
|
+
describe: "Run a localhost Subsonic API for music clients such as Kopuz"
|
|
2481
|
+
}).option("apiPort", {
|
|
2482
|
+
type: "number",
|
|
2483
|
+
default: 4533,
|
|
2484
|
+
describe: "Port for --api mode (binds to 127.0.0.1)"
|
|
2485
|
+
}).option("reload", {
|
|
1640
2486
|
type: "boolean",
|
|
1641
2487
|
default: false,
|
|
1642
2488
|
alias: "r",
|
|
@@ -1658,42 +2504,38 @@ Usage: $0 [options]`).option("reload", {
|
|
|
1658
2504
|
default: false,
|
|
1659
2505
|
alias: "l",
|
|
1660
2506
|
describe: "Loop the playlist when playback reaches the end"
|
|
2507
|
+
}).option("shuffle", {
|
|
2508
|
+
type: "boolean",
|
|
2509
|
+
default: false,
|
|
2510
|
+
alias: "s",
|
|
2511
|
+
describe: "Play the playlist in shuffled order"
|
|
2512
|
+
}).check((argv) => {
|
|
2513
|
+
if (!Number.isInteger(argv.apiPort) || argv.apiPort < 1 || argv.apiPort > 65535) {
|
|
2514
|
+
throw new Error("--apiPort must be an integer between 1 and 65535");
|
|
2515
|
+
}
|
|
2516
|
+
const conflictingMode = [
|
|
2517
|
+
argv.exportPlaylist ? "exportPlaylist" : null,
|
|
2518
|
+
argv.loop ? "loop" : null,
|
|
2519
|
+
argv.shuffle ? "shuffle" : null
|
|
2520
|
+
].find(Boolean);
|
|
2521
|
+
if (argv.api && conflictingMode) {
|
|
2522
|
+
throw new Error(`--api cannot be combined with --${conflictingMode}`);
|
|
2523
|
+
}
|
|
2524
|
+
return true;
|
|
1661
2525
|
}).alias("help", "h").help().parse();
|
|
1662
2526
|
}
|
|
1663
|
-
async function createPlaylist(osuDataDir) {
|
|
1664
|
-
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
1665
|
-
if (!realmDBPath) {
|
|
1666
|
-
throw new Error("Realm DB not found");
|
|
1667
|
-
}
|
|
1668
|
-
const {
|
|
1669
|
-
Realm: Realm8,
|
|
1670
|
-
formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
|
|
1671
|
-
getBeatmapSets: getBeatmapSets2,
|
|
1672
|
-
getLazerDB: getLazerDB2,
|
|
1673
|
-
inspectLazerSchema: inspectLazerSchema2
|
|
1674
|
-
} = await loadRealmDependencies();
|
|
1675
|
-
Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
|
|
1676
|
-
const realm = await getLazerDB2(realmDBPath);
|
|
1677
|
-
try {
|
|
1678
|
-
const schemaReport = inspectLazerSchema2(realm);
|
|
1679
|
-
if (!schemaReport.compatible) {
|
|
1680
|
-
throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
|
|
1681
|
-
}
|
|
1682
|
-
return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
|
|
1683
|
-
} finally {
|
|
1684
|
-
realm.close();
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
2527
|
async function deleteTrackFromCollection(track, osuDataDir) {
|
|
1688
|
-
|
|
1689
|
-
await deleteBeatmapSet2(track, osuDataDir);
|
|
2528
|
+
await deleteBeatmapSetFromLazer(track, osuDataDir);
|
|
1690
2529
|
}
|
|
1691
|
-
async function runTuiPlayer(playlist, osuDataDir, loop) {
|
|
2530
|
+
async function runTuiPlayer(playlist, osuDataDir, loop, shuffle) {
|
|
2531
|
+
const { revealFile: revealFile2 } = await Promise.resolve().then(() => (init_mod(), exports_mod));
|
|
1692
2532
|
const backend = new MpvPlayerBackend;
|
|
1693
2533
|
const session = new PlaylistPlayerSession(playlist, backend, {
|
|
1694
2534
|
deleteTrack: (track) => deleteTrackFromCollection(track, osuDataDir),
|
|
1695
2535
|
loop,
|
|
1696
|
-
reloadPlaylist: () =>
|
|
2536
|
+
reloadPlaylist: () => loadPlaylistFromLazer(osuDataDir),
|
|
2537
|
+
revealTrack: (track) => revealFile2(track.path),
|
|
2538
|
+
shuffle
|
|
1697
2539
|
});
|
|
1698
2540
|
const terminal = new import_pi_tui2.ProcessTerminal;
|
|
1699
2541
|
const tui = new import_pi_tui2.TUI(terminal);
|
|
@@ -1724,6 +2566,30 @@ async function runTuiPlayer(playlist, osuDataDir, loop) {
|
|
|
1724
2566
|
await session.dispose();
|
|
1725
2567
|
}
|
|
1726
2568
|
}
|
|
2569
|
+
async function runApiMode(osuDataDir, port) {
|
|
2570
|
+
const catalogStore = await createLazerCatalogStore(osuDataDir);
|
|
2571
|
+
const server = await startApiServer({
|
|
2572
|
+
catalogStore,
|
|
2573
|
+
port
|
|
2574
|
+
});
|
|
2575
|
+
console.log(`[INFO] osu-play API listening at ${server.url}`);
|
|
2576
|
+
console.log("[INFO] In Kopuz, add a Custom server with this URL and any non-empty username/password.");
|
|
2577
|
+
console.log("[INFO] Press Ctrl+C to stop.");
|
|
2578
|
+
let resolveShutdown;
|
|
2579
|
+
const shutdown = new Promise((resolve) => {
|
|
2580
|
+
resolveShutdown = resolve;
|
|
2581
|
+
});
|
|
2582
|
+
const handleSignal = () => resolveShutdown?.();
|
|
2583
|
+
process.once("SIGINT", handleSignal);
|
|
2584
|
+
process.once("SIGTERM", handleSignal);
|
|
2585
|
+
try {
|
|
2586
|
+
await shutdown;
|
|
2587
|
+
} finally {
|
|
2588
|
+
process.off("SIGINT", handleSignal);
|
|
2589
|
+
process.off("SIGTERM", handleSignal);
|
|
2590
|
+
await server.close();
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
1727
2593
|
async function main() {
|
|
1728
2594
|
const argv = await getArgs();
|
|
1729
2595
|
if (argv.reload) {
|
|
@@ -1732,16 +2598,20 @@ async function main() {
|
|
|
1732
2598
|
if (argv.configDir) {
|
|
1733
2599
|
console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
|
|
1734
2600
|
}
|
|
1735
|
-
|
|
2601
|
+
if (argv.api) {
|
|
2602
|
+
await runApiMode(argv.osuDataDir, argv.apiPort);
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
const playlist = await loadPlaylistFromLazer(argv.osuDataDir);
|
|
1736
2606
|
if (argv.exportPlaylist) {
|
|
1737
2607
|
const playlistContents = playlist.map((track) => track.path).join(`
|
|
1738
2608
|
`);
|
|
1739
|
-
|
|
2609
|
+
import_node_fs4.writeFileSync(argv.exportPlaylist, playlistContents);
|
|
1740
2610
|
console.log(`[INFO] Exported ${playlist.length} tracks to ${argv.exportPlaylist}.`);
|
|
1741
|
-
console.log(`[INFO] Use something like \`mpv --playlist=${
|
|
2611
|
+
console.log(`[INFO] Use something like \`mpv --playlist=${import_node_path4.default.resolve(argv.exportPlaylist)}\` to play the playlist.`);
|
|
1742
2612
|
return;
|
|
1743
2613
|
}
|
|
1744
|
-
await runTuiPlayer(playlist, argv.osuDataDir, argv.loop);
|
|
2614
|
+
await runTuiPlayer(playlist, argv.osuDataDir, argv.loop, argv.shuffle);
|
|
1745
2615
|
}
|
|
1746
2616
|
|
|
1747
2617
|
// src/cli.ts
|
|
@@ -1752,4 +2622,4 @@ main().then(() => {
|
|
|
1752
2622
|
process.exit(1);
|
|
1753
2623
|
});
|
|
1754
2624
|
|
|
1755
|
-
//# debugId=
|
|
2625
|
+
//# debugId=B22693114D23F24364756E2164756E21
|