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.js
CHANGED
|
@@ -29,8 +29,33 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
|
29
29
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
30
30
|
|
|
31
31
|
// src/core/utils/mod.ts
|
|
32
|
-
|
|
32
|
+
var exports_mod = {};
|
|
33
|
+
__export(exports_mod, {
|
|
34
|
+
revealFile: () => revealFile,
|
|
35
|
+
hashedFilePath: () => hashedFilePath,
|
|
36
|
+
getRealmDBPath: () => getRealmDBPath,
|
|
37
|
+
getDefaultOsuDataDir: () => getDefaultOsuDataDir,
|
|
38
|
+
getDefaultConfigPath: () => getDefaultConfigPath,
|
|
39
|
+
getDataDir: () => getDataDir,
|
|
40
|
+
getConfigDir: () => getConfigDir
|
|
41
|
+
});
|
|
42
|
+
import path2 from "node:path";
|
|
33
43
|
import { existsSync } from "node:fs";
|
|
44
|
+
import { spawn } from "node:child_process";
|
|
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: [path2.dirname(filePath)], executable: "xdg-open" };
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const child = spawn(command.executable, command.args, {
|
|
49
|
+
detached: true,
|
|
50
|
+
stdio: "ignore"
|
|
51
|
+
});
|
|
52
|
+
child.once("error", reject);
|
|
53
|
+
child.once("spawn", () => {
|
|
54
|
+
child.unref();
|
|
55
|
+
resolve();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
34
59
|
function getDataDir() {
|
|
35
60
|
switch (process.platform) {
|
|
36
61
|
case "linux":
|
|
@@ -41,13 +66,13 @@ function getDataDir() {
|
|
|
41
66
|
return xdg;
|
|
42
67
|
const home = process.env.HOME;
|
|
43
68
|
if (home)
|
|
44
|
-
return
|
|
69
|
+
return path2.join(home, ".local", "share");
|
|
45
70
|
break;
|
|
46
71
|
}
|
|
47
72
|
case "darwin": {
|
|
48
73
|
const home = process.env.HOME;
|
|
49
74
|
if (home)
|
|
50
|
-
return
|
|
75
|
+
return path2.join(home, "Library", "Application Support");
|
|
51
76
|
break;
|
|
52
77
|
}
|
|
53
78
|
case "win32":
|
|
@@ -65,13 +90,13 @@ function getConfigDir() {
|
|
|
65
90
|
return xdg;
|
|
66
91
|
const home = process.env.HOME;
|
|
67
92
|
if (home)
|
|
68
|
-
return
|
|
93
|
+
return path2.join(home, ".config");
|
|
69
94
|
break;
|
|
70
95
|
}
|
|
71
96
|
case "darwin": {
|
|
72
97
|
const home = process.env.HOME;
|
|
73
98
|
if (home)
|
|
74
|
-
return
|
|
99
|
+
return path2.join(home, "Library", "Preferences");
|
|
75
100
|
break;
|
|
76
101
|
}
|
|
77
102
|
case "win32":
|
|
@@ -80,18 +105,18 @@ function getConfigDir() {
|
|
|
80
105
|
return;
|
|
81
106
|
}
|
|
82
107
|
function getDefaultOsuDataDir() {
|
|
83
|
-
return
|
|
108
|
+
return path2.join(getDataDir() ?? ".", "osu");
|
|
84
109
|
}
|
|
85
110
|
function getDefaultConfigPath(appName = "osu-play") {
|
|
86
|
-
return
|
|
111
|
+
return path2.join(getConfigDir() ?? ".", appName);
|
|
87
112
|
}
|
|
88
113
|
function hashedFilePath(hash, osuDataDir = getDefaultOsuDataDir()) {
|
|
89
|
-
return
|
|
114
|
+
return path2.join(osuDataDir, "files", hash.slice(0, 1), hash.slice(0, 2), hash);
|
|
90
115
|
}
|
|
91
116
|
function getRealmDBPath(legacyAppConfigDirOrOptions = {}, maybeOptions = {}) {
|
|
92
117
|
const options = typeof legacyAppConfigDirOrOptions === "string" ? maybeOptions : legacyAppConfigDirOrOptions;
|
|
93
118
|
const osuDataDir = options.osuDataDir ?? getDefaultOsuDataDir();
|
|
94
|
-
const osuDBPath =
|
|
119
|
+
const osuDBPath = path2.join(osuDataDir, "client.realm");
|
|
95
120
|
if (!existsSync(osuDBPath)) {
|
|
96
121
|
console.log(`[getRealmDBPath]: ${osuDBPath} not found`);
|
|
97
122
|
return null;
|
|
@@ -400,8 +425,8 @@ var init_mod2 = __esm(() => {
|
|
|
400
425
|
});
|
|
401
426
|
|
|
402
427
|
// src/core/lazer/mod.ts
|
|
403
|
-
var
|
|
404
|
-
__export(
|
|
428
|
+
var exports_mod2 = {};
|
|
429
|
+
__export(exports_mod2, {
|
|
405
430
|
inspectLazerSchemaEntries: () => inspectLazerSchemaEntries,
|
|
406
431
|
inspectLazerSchema: () => inspectLazerSchema,
|
|
407
432
|
hashedFilePath: () => hashedFilePath,
|
|
@@ -489,12 +514,615 @@ var init_mod3 = __esm(() => {
|
|
|
489
514
|
});
|
|
490
515
|
|
|
491
516
|
// src/core/cli/main.ts
|
|
492
|
-
import
|
|
517
|
+
import path4 from "node:path";
|
|
493
518
|
import { writeFileSync } from "node:fs";
|
|
494
519
|
import { ProcessTerminal, TUI } from "@earendil-works/pi-tui";
|
|
495
520
|
import yargs from "yargs/yargs";
|
|
496
521
|
import { hideBin } from "yargs/helpers";
|
|
497
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
|
+
|
|
498
1126
|
// src/core/playlist/mod.ts
|
|
499
1127
|
init_mod();
|
|
500
1128
|
function uniqueParts(parts) {
|
|
@@ -539,12 +1167,123 @@ function buildPlaylist(beatmapSets, osuDataDir) {
|
|
|
539
1167
|
return playlist;
|
|
540
1168
|
}
|
|
541
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
|
+
|
|
542
1281
|
// src/core/player/mpv.ts
|
|
543
|
-
import { spawn } from "node:child_process";
|
|
1282
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
544
1283
|
import { existsSync as existsSync2, rmSync } from "node:fs";
|
|
545
1284
|
import net from "node:net";
|
|
546
1285
|
import os from "node:os";
|
|
547
|
-
import
|
|
1286
|
+
import path3 from "node:path";
|
|
548
1287
|
import { setTimeout as delay } from "node:timers/promises";
|
|
549
1288
|
function getErrorCode(error) {
|
|
550
1289
|
if (error && typeof error === "object" && "code" in error) {
|
|
@@ -566,7 +1305,7 @@ function createSocketPath() {
|
|
|
566
1305
|
if (process.platform === "win32") {
|
|
567
1306
|
return `\\\\.\\pipe\\${id}`;
|
|
568
1307
|
}
|
|
569
|
-
return
|
|
1308
|
+
return path3.join(os.tmpdir(), `${id}.sock`);
|
|
570
1309
|
}
|
|
571
1310
|
function isControlText(value) {
|
|
572
1311
|
return [...value].some((character) => {
|
|
@@ -695,7 +1434,7 @@ class MpvPlayerBackend {
|
|
|
695
1434
|
this.cleanupSocketPath();
|
|
696
1435
|
this.disposing = false;
|
|
697
1436
|
this.stderrBuffer = "";
|
|
698
|
-
const processHandle =
|
|
1437
|
+
const processHandle = spawn2("mpv", [
|
|
699
1438
|
"--no-config",
|
|
700
1439
|
"--no-terminal",
|
|
701
1440
|
"--idle=yes",
|
|
@@ -974,6 +1713,13 @@ function findTrackIndexByQuery(playlist, query) {
|
|
|
974
1713
|
}
|
|
975
1714
|
return playlist.findIndex((track) => normalizeSearchText(track.title).includes(normalizedQuery));
|
|
976
1715
|
}
|
|
1716
|
+
function findTrackIndicesByQuery(playlist, query) {
|
|
1717
|
+
const normalizedQuery = normalizeSearchText(query);
|
|
1718
|
+
if (!normalizedQuery) {
|
|
1719
|
+
return playlist.map((_, index) => index);
|
|
1720
|
+
}
|
|
1721
|
+
return playlist.flatMap((track, index) => normalizeSearchText(track.title).includes(normalizedQuery) ? [index] : []);
|
|
1722
|
+
}
|
|
977
1723
|
|
|
978
1724
|
class PlaylistPlayerSession {
|
|
979
1725
|
backend;
|
|
@@ -983,9 +1729,13 @@ class PlaylistPlayerSession {
|
|
|
983
1729
|
loop;
|
|
984
1730
|
deleteTrack;
|
|
985
1731
|
playlist;
|
|
1732
|
+
random;
|
|
986
1733
|
reloadPlaylist;
|
|
1734
|
+
revealTrack;
|
|
987
1735
|
searchQuery = "";
|
|
988
1736
|
selectedIndex = 0;
|
|
1737
|
+
shuffle;
|
|
1738
|
+
shuffleOrder = [];
|
|
989
1739
|
operationQueue = Promise.resolve();
|
|
990
1740
|
unsubscribeBackend;
|
|
991
1741
|
constructor(playlist, backend, options = {}) {
|
|
@@ -993,7 +1743,13 @@ class PlaylistPlayerSession {
|
|
|
993
1743
|
this.playlist = playlist;
|
|
994
1744
|
this.deleteTrack = options.deleteTrack;
|
|
995
1745
|
this.loop = options.loop ?? false;
|
|
1746
|
+
this.random = options.random ?? Math.random;
|
|
996
1747
|
this.reloadPlaylist = options.reloadPlaylist;
|
|
1748
|
+
this.revealTrack = options.revealTrack;
|
|
1749
|
+
this.shuffle = options.shuffle ?? false;
|
|
1750
|
+
if (this.shuffle) {
|
|
1751
|
+
this.resetShuffleOrder(this.selectedIndex);
|
|
1752
|
+
}
|
|
997
1753
|
this.unsubscribeBackend = backend.subscribe((event) => {
|
|
998
1754
|
this.handleBackendEvent(event);
|
|
999
1755
|
});
|
|
@@ -1011,6 +1767,7 @@ class PlaylistPlayerSession {
|
|
|
1011
1767
|
playlist: this.playlist,
|
|
1012
1768
|
searchQuery: this.searchQuery,
|
|
1013
1769
|
selectedIndex: this.selectedIndex,
|
|
1770
|
+
shuffle: this.shuffle,
|
|
1014
1771
|
status: backendSnapshot.status,
|
|
1015
1772
|
timePositionSeconds: backendSnapshot.timePositionSeconds
|
|
1016
1773
|
};
|
|
@@ -1034,24 +1791,44 @@ class PlaylistPlayerSession {
|
|
|
1034
1791
|
if (this.playlist.length === 0) {
|
|
1035
1792
|
return;
|
|
1036
1793
|
}
|
|
1794
|
+
if (this.searchQuery) {
|
|
1795
|
+
this.moveSearchSelection(delta);
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1037
1798
|
this.selectedIndex = wrapIndex(this.selectedIndex + delta, this.playlist.length);
|
|
1038
1799
|
this.emit();
|
|
1039
1800
|
}
|
|
1040
1801
|
moveSelectionPage(delta, pageSize) {
|
|
1802
|
+
if (this.searchQuery) {
|
|
1803
|
+
this.moveSearchSelection(delta * Math.max(1, pageSize));
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1041
1806
|
this.moveSelection(delta * Math.max(1, pageSize));
|
|
1042
1807
|
}
|
|
1808
|
+
moveSearchSelection(delta) {
|
|
1809
|
+
const matches = findTrackIndicesByQuery(this.playlist, this.searchQuery);
|
|
1810
|
+
if (matches.length === 0) {
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
const currentMatchIndex = matches.indexOf(this.selectedIndex);
|
|
1814
|
+
const nextMatchIndex = wrapIndex(currentMatchIndex + delta, matches.length);
|
|
1815
|
+
this.selectedIndex = matches[nextMatchIndex] ?? this.selectedIndex;
|
|
1816
|
+
this.emit();
|
|
1817
|
+
}
|
|
1043
1818
|
selectHome() {
|
|
1044
1819
|
if (this.playlist.length === 0) {
|
|
1045
1820
|
return;
|
|
1046
1821
|
}
|
|
1047
|
-
this.
|
|
1822
|
+
const matches = findTrackIndicesByQuery(this.playlist, this.searchQuery);
|
|
1823
|
+
this.selectedIndex = matches[0] ?? 0;
|
|
1048
1824
|
this.emit();
|
|
1049
1825
|
}
|
|
1050
1826
|
selectEnd() {
|
|
1051
1827
|
if (this.playlist.length === 0) {
|
|
1052
1828
|
return;
|
|
1053
1829
|
}
|
|
1054
|
-
|
|
1830
|
+
const matches = findTrackIndicesByQuery(this.playlist, this.searchQuery);
|
|
1831
|
+
this.selectedIndex = matches.at(-1) ?? this.playlist.length - 1;
|
|
1055
1832
|
this.emit();
|
|
1056
1833
|
}
|
|
1057
1834
|
setSelectionIndex(index) {
|
|
@@ -1065,30 +1842,43 @@ class PlaylistPlayerSession {
|
|
|
1065
1842
|
this.loop = !this.loop;
|
|
1066
1843
|
this.emit();
|
|
1067
1844
|
}
|
|
1845
|
+
toggleShuffle() {
|
|
1846
|
+
this.shuffle = !this.shuffle;
|
|
1847
|
+
if (this.shuffle) {
|
|
1848
|
+
this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
|
|
1849
|
+
}
|
|
1850
|
+
this.emit();
|
|
1851
|
+
}
|
|
1068
1852
|
appendSearchQuery(text) {
|
|
1069
|
-
if (!text)
|
|
1853
|
+
if (!text)
|
|
1070
1854
|
return;
|
|
1071
|
-
}
|
|
1072
1855
|
this.searchQuery += text;
|
|
1073
1856
|
this.syncSelectionToSearch();
|
|
1074
1857
|
}
|
|
1075
1858
|
deleteSearchCharacter() {
|
|
1076
|
-
if (!this.searchQuery)
|
|
1859
|
+
if (!this.searchQuery)
|
|
1077
1860
|
return;
|
|
1078
|
-
}
|
|
1079
1861
|
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
1080
1862
|
this.syncSelectionToSearch();
|
|
1081
1863
|
}
|
|
1864
|
+
deleteSearchWord() {
|
|
1865
|
+
if (!this.searchQuery)
|
|
1866
|
+
return;
|
|
1867
|
+
this.searchQuery = this.searchQuery.replace(/\s*\S+\s*$/, "");
|
|
1868
|
+
this.syncSelectionToSearch();
|
|
1869
|
+
}
|
|
1082
1870
|
clearSearch() {
|
|
1083
|
-
if (!this.searchQuery)
|
|
1871
|
+
if (!this.searchQuery)
|
|
1084
1872
|
return;
|
|
1085
|
-
}
|
|
1086
1873
|
this.searchQuery = "";
|
|
1087
1874
|
this.emit();
|
|
1088
1875
|
}
|
|
1089
1876
|
async playSelected() {
|
|
1090
1877
|
const selectedIndex = this.selectedIndex;
|
|
1091
1878
|
await this.enqueueOperation(async () => {
|
|
1879
|
+
if (this.shuffle) {
|
|
1880
|
+
this.resetShuffleOrder(selectedIndex);
|
|
1881
|
+
}
|
|
1092
1882
|
await this.playIndex(selectedIndex);
|
|
1093
1883
|
});
|
|
1094
1884
|
}
|
|
@@ -1157,6 +1947,22 @@ class PlaylistPlayerSession {
|
|
|
1157
1947
|
}
|
|
1158
1948
|
});
|
|
1159
1949
|
}
|
|
1950
|
+
async revealSelectedTrack() {
|
|
1951
|
+
const track = this.playlist[this.selectedIndex];
|
|
1952
|
+
if (!track) {
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (!this.revealTrack) {
|
|
1956
|
+
this.reportError(new Error("Opening the containing folder is unavailable."));
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
try {
|
|
1960
|
+
this.clearError();
|
|
1961
|
+
await this.revealTrack(track);
|
|
1962
|
+
} catch (error) {
|
|
1963
|
+
this.reportError(error);
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1160
1966
|
async deleteSelectedTrack() {
|
|
1161
1967
|
const selectedIndex = this.selectedIndex;
|
|
1162
1968
|
await this.enqueueOperation(async () => {
|
|
@@ -1189,6 +1995,9 @@ class PlaylistPlayerSession {
|
|
|
1189
1995
|
const nextCurrentIndex = this.playlist.findIndex((playlistTrack) => playlistTrack.hash === currentTrack.hash);
|
|
1190
1996
|
this.currentIndex = nextCurrentIndex === -1 ? null : nextCurrentIndex;
|
|
1191
1997
|
}
|
|
1998
|
+
if (this.shuffle) {
|
|
1999
|
+
this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
|
|
2000
|
+
}
|
|
1192
2001
|
this.emit();
|
|
1193
2002
|
} catch (error) {
|
|
1194
2003
|
this.reportError(error);
|
|
@@ -1234,6 +2043,19 @@ class PlaylistPlayerSession {
|
|
|
1234
2043
|
return null;
|
|
1235
2044
|
}
|
|
1236
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
|
+
}
|
|
1237
2059
|
const nextIndex = baseIndex + delta;
|
|
1238
2060
|
const shouldWrap = wrap || this.loop;
|
|
1239
2061
|
if (nextIndex < 0) {
|
|
@@ -1244,6 +2066,17 @@ class PlaylistPlayerSession {
|
|
|
1244
2066
|
}
|
|
1245
2067
|
return nextIndex;
|
|
1246
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
|
+
}
|
|
1247
2080
|
handleBackendEvent(event) {
|
|
1248
2081
|
switch (event.type) {
|
|
1249
2082
|
case "state":
|
|
@@ -1463,6 +2296,14 @@ class PlaylistPlayerScreen {
|
|
|
1463
2296
|
this.session.toggleLoop();
|
|
1464
2297
|
return;
|
|
1465
2298
|
}
|
|
2299
|
+
if (matchesKey(data, "x")) {
|
|
2300
|
+
this.session.toggleShuffle();
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
if (matchesKey(data, "o")) {
|
|
2304
|
+
this.session.revealSelectedTrack();
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
1466
2307
|
if (matchesKey(data, Key.backspace)) {
|
|
1467
2308
|
this.session.deleteSearchCharacter();
|
|
1468
2309
|
return;
|
|
@@ -1474,9 +2315,11 @@ class PlaylistPlayerScreen {
|
|
|
1474
2315
|
}
|
|
1475
2316
|
render(width) {
|
|
1476
2317
|
const { playlist } = this.snapshot;
|
|
2318
|
+
const visibleIndices = this.snapshot.searchQuery ? findTrackIndicesByQuery(playlist, this.snapshot.searchQuery) : playlist.map((_, index) => index);
|
|
2319
|
+
const selectedVisibleIndex = Math.max(0, visibleIndices.indexOf(this.snapshot.selectedIndex));
|
|
1477
2320
|
const viewportHeight = Math.max(this.getViewportHeight(), RESERVED_ROWS);
|
|
1478
2321
|
const listHeight = this.getListHeight(viewportHeight);
|
|
1479
|
-
const { start, end } = getVisibleTrackRange(
|
|
2322
|
+
const { start, end } = getVisibleTrackRange(selectedVisibleIndex, visibleIndices.length, listHeight);
|
|
1480
2323
|
const position = formatSeconds(this.snapshot.timePositionSeconds);
|
|
1481
2324
|
const duration = formatSeconds(this.snapshot.durationSeconds);
|
|
1482
2325
|
const glyph = STATUS_GLYPH[this.snapshot.status] ?? "·";
|
|
@@ -1494,12 +2337,18 @@ class PlaylistPlayerScreen {
|
|
|
1494
2337
|
} else if (this.searchMode || this.snapshot.searchQuery) {
|
|
1495
2338
|
lines.push(truncateToWidth(style(`/ ${this.snapshot.searchQuery}${this.searchMode ? "▏" : ""}`, CYAN), width));
|
|
1496
2339
|
} else {
|
|
1497
|
-
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));
|
|
1498
2341
|
}
|
|
1499
2342
|
if (playlist.length === 0) {
|
|
1500
2343
|
lines.push(truncateToWidth("No tracks were found in your osu!lazer library.", width));
|
|
2344
|
+
} else if (visibleIndices.length === 0) {
|
|
2345
|
+
lines.push(truncateToWidth("No tracks match the current search.", width));
|
|
1501
2346
|
} else {
|
|
1502
|
-
for (let
|
|
2347
|
+
for (let visibleIndex = start;visibleIndex < end; visibleIndex += 1) {
|
|
2348
|
+
const index = visibleIndices[visibleIndex];
|
|
2349
|
+
if (index === undefined) {
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
1503
2352
|
const track = playlist[index];
|
|
1504
2353
|
if (!track) {
|
|
1505
2354
|
continue;
|
|
@@ -1514,7 +2363,7 @@ class PlaylistPlayerScreen {
|
|
|
1514
2363
|
while (lines.length < viewportHeight - 1) {
|
|
1515
2364
|
lines.push("");
|
|
1516
2365
|
}
|
|
1517
|
-
lines.push(truncateToWidth(style(this.searchMode ? "type to
|
|
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));
|
|
1518
2367
|
return lines;
|
|
1519
2368
|
}
|
|
1520
2369
|
armPendingGoToTop() {
|
|
@@ -1556,6 +2405,7 @@ class PlaylistPlayerScreen {
|
|
|
1556
2405
|
}
|
|
1557
2406
|
if (matchesKey(data, Key.enter)) {
|
|
1558
2407
|
this.searchMode = false;
|
|
2408
|
+
this.session.playSelected();
|
|
1559
2409
|
return;
|
|
1560
2410
|
}
|
|
1561
2411
|
if (matchesKey(data, Key.escape)) {
|
|
@@ -1566,10 +2416,22 @@ class PlaylistPlayerScreen {
|
|
|
1566
2416
|
this.session.deleteSearchCharacter();
|
|
1567
2417
|
return;
|
|
1568
2418
|
}
|
|
2419
|
+
if (matchesKey(data, Key.ctrl("w"))) {
|
|
2420
|
+
this.session.deleteSearchWord();
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
1569
2423
|
if (matchesKey(data, Key.ctrl("u"))) {
|
|
1570
2424
|
this.session.clearSearch();
|
|
1571
2425
|
return;
|
|
1572
2426
|
}
|
|
2427
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) {
|
|
2428
|
+
this.session.moveSearchSelection(-1);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) {
|
|
2432
|
+
this.session.moveSearchSelection(1);
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
1573
2435
|
const printable = decodePrintableText(data);
|
|
1574
2436
|
if (!printable) {
|
|
1575
2437
|
return;
|
|
@@ -1600,33 +2462,17 @@ class PlaylistPlayerScreen {
|
|
|
1600
2462
|
|
|
1601
2463
|
// src/core/cli/main.ts
|
|
1602
2464
|
init_mod();
|
|
1603
|
-
function formatRealmLoadError(error) {
|
|
1604
|
-
return [
|
|
1605
|
-
"Realm native bindings could not be loaded.",
|
|
1606
|
-
`Current Node runtime: ${process.version}.`,
|
|
1607
|
-
"To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
|
|
1608
|
-
"If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
|
|
1609
|
-
`Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
1610
|
-
].join(`
|
|
1611
|
-
`);
|
|
1612
|
-
}
|
|
1613
|
-
async function loadRealmDependencies() {
|
|
1614
|
-
try {
|
|
1615
|
-
const [{ default: Realm8 }, lazerModule] = await Promise.all([
|
|
1616
|
-
import("realm"),
|
|
1617
|
-
Promise.resolve().then(() => (init_mod3(), exports_mod))
|
|
1618
|
-
]);
|
|
1619
|
-
return {
|
|
1620
|
-
Realm: Realm8,
|
|
1621
|
-
...lazerModule
|
|
1622
|
-
};
|
|
1623
|
-
} catch (error) {
|
|
1624
|
-
throw new Error(formatRealmLoadError(error), { cause: error });
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
2465
|
function getArgs() {
|
|
1628
|
-
return yargs(hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps
|
|
1629
|
-
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", {
|
|
1630
2476
|
type: "boolean",
|
|
1631
2477
|
default: false,
|
|
1632
2478
|
alias: "r",
|
|
@@ -1648,42 +2494,38 @@ Usage: $0 [options]`).option("reload", {
|
|
|
1648
2494
|
default: false,
|
|
1649
2495
|
alias: "l",
|
|
1650
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;
|
|
1651
2515
|
}).alias("help", "h").help().parse();
|
|
1652
2516
|
}
|
|
1653
|
-
async function createPlaylist(osuDataDir) {
|
|
1654
|
-
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
1655
|
-
if (!realmDBPath) {
|
|
1656
|
-
throw new Error("Realm DB not found");
|
|
1657
|
-
}
|
|
1658
|
-
const {
|
|
1659
|
-
Realm: Realm8,
|
|
1660
|
-
formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
|
|
1661
|
-
getBeatmapSets: getBeatmapSets2,
|
|
1662
|
-
getLazerDB: getLazerDB2,
|
|
1663
|
-
inspectLazerSchema: inspectLazerSchema2
|
|
1664
|
-
} = await loadRealmDependencies();
|
|
1665
|
-
Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
|
|
1666
|
-
const realm = await getLazerDB2(realmDBPath);
|
|
1667
|
-
try {
|
|
1668
|
-
const schemaReport = inspectLazerSchema2(realm);
|
|
1669
|
-
if (!schemaReport.compatible) {
|
|
1670
|
-
throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
|
|
1671
|
-
}
|
|
1672
|
-
return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
|
|
1673
|
-
} finally {
|
|
1674
|
-
realm.close();
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
2517
|
async function deleteTrackFromCollection(track, osuDataDir) {
|
|
1678
|
-
|
|
1679
|
-
await deleteBeatmapSet2(track, osuDataDir);
|
|
2518
|
+
await deleteBeatmapSetFromLazer(track, osuDataDir);
|
|
1680
2519
|
}
|
|
1681
|
-
async function runTuiPlayer(playlist, osuDataDir, loop) {
|
|
2520
|
+
async function runTuiPlayer(playlist, osuDataDir, loop, shuffle) {
|
|
2521
|
+
const { revealFile: revealFile2 } = await Promise.resolve().then(() => (init_mod(), exports_mod));
|
|
1682
2522
|
const backend = new MpvPlayerBackend;
|
|
1683
2523
|
const session = new PlaylistPlayerSession(playlist, backend, {
|
|
1684
2524
|
deleteTrack: (track) => deleteTrackFromCollection(track, osuDataDir),
|
|
1685
2525
|
loop,
|
|
1686
|
-
reloadPlaylist: () =>
|
|
2526
|
+
reloadPlaylist: () => loadPlaylistFromLazer(osuDataDir),
|
|
2527
|
+
revealTrack: (track) => revealFile2(track.path),
|
|
2528
|
+
shuffle
|
|
1687
2529
|
});
|
|
1688
2530
|
const terminal = new ProcessTerminal;
|
|
1689
2531
|
const tui = new TUI(terminal);
|
|
@@ -1714,6 +2556,30 @@ async function runTuiPlayer(playlist, osuDataDir, loop) {
|
|
|
1714
2556
|
await session.dispose();
|
|
1715
2557
|
}
|
|
1716
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
|
+
}
|
|
1717
2583
|
async function main() {
|
|
1718
2584
|
const argv = await getArgs();
|
|
1719
2585
|
if (argv.reload) {
|
|
@@ -1722,16 +2588,20 @@ async function main() {
|
|
|
1722
2588
|
if (argv.configDir) {
|
|
1723
2589
|
console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
|
|
1724
2590
|
}
|
|
1725
|
-
|
|
2591
|
+
if (argv.api) {
|
|
2592
|
+
await runApiMode(argv.osuDataDir, argv.apiPort);
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
const playlist = await loadPlaylistFromLazer(argv.osuDataDir);
|
|
1726
2596
|
if (argv.exportPlaylist) {
|
|
1727
2597
|
const playlistContents = playlist.map((track) => track.path).join(`
|
|
1728
2598
|
`);
|
|
1729
2599
|
writeFileSync(argv.exportPlaylist, playlistContents);
|
|
1730
2600
|
console.log(`[INFO] Exported ${playlist.length} tracks to ${argv.exportPlaylist}.`);
|
|
1731
|
-
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.`);
|
|
1732
2602
|
return;
|
|
1733
2603
|
}
|
|
1734
|
-
await runTuiPlayer(playlist, argv.osuDataDir, argv.loop);
|
|
2604
|
+
await runTuiPlayer(playlist, argv.osuDataDir, argv.loop, argv.shuffle);
|
|
1735
2605
|
}
|
|
1736
2606
|
|
|
1737
2607
|
// src/cli.ts
|
|
@@ -1742,4 +2612,4 @@ main().then(() => {
|
|
|
1742
2612
|
process.exit(1);
|
|
1743
2613
|
});
|
|
1744
2614
|
|
|
1745
|
-
//# debugId=
|
|
2615
|
+
//# debugId=7C34DADA116CE55564756E2164756E21
|