osu-play 1.3.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -4
- package/dist/cli.cjs +868 -91
- package/dist/cli.cjs.map +9 -6
- package/dist/cli.js +857 -80
- package/dist/cli.js.map +9 -6
- package/dist/core/api/catalog.d.ts +61 -0
- package/dist/core/api/server.d.ts +13 -0
- package/dist/core/cli/main.d.ts +6 -0
- package/dist/core/lazer/library.d.ts +17 -0
- package/dist/core/player/session.d.ts +8 -0
- package/dist/core/player/types.d.ts +1 -0
- package/package.json +5 -2
package/dist/cli.cjs
CHANGED
|
@@ -53,7 +53,7 @@ __export(exports_mod, {
|
|
|
53
53
|
getConfigDir: () => getConfigDir
|
|
54
54
|
});
|
|
55
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: [
|
|
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
57
|
return new Promise((resolve, reject) => {
|
|
58
58
|
const child = import_node_child_process.spawn(command.executable, command.args, {
|
|
59
59
|
detached: true,
|
|
@@ -76,13 +76,13 @@ function getDataDir() {
|
|
|
76
76
|
return xdg;
|
|
77
77
|
const home = process.env.HOME;
|
|
78
78
|
if (home)
|
|
79
|
-
return
|
|
79
|
+
return import_node_path2.default.join(home, ".local", "share");
|
|
80
80
|
break;
|
|
81
81
|
}
|
|
82
82
|
case "darwin": {
|
|
83
83
|
const home = process.env.HOME;
|
|
84
84
|
if (home)
|
|
85
|
-
return
|
|
85
|
+
return import_node_path2.default.join(home, "Library", "Application Support");
|
|
86
86
|
break;
|
|
87
87
|
}
|
|
88
88
|
case "win32":
|
|
@@ -100,13 +100,13 @@ function getConfigDir() {
|
|
|
100
100
|
return xdg;
|
|
101
101
|
const home = process.env.HOME;
|
|
102
102
|
if (home)
|
|
103
|
-
return
|
|
103
|
+
return import_node_path2.default.join(home, ".config");
|
|
104
104
|
break;
|
|
105
105
|
}
|
|
106
106
|
case "darwin": {
|
|
107
107
|
const home = process.env.HOME;
|
|
108
108
|
if (home)
|
|
109
|
-
return
|
|
109
|
+
return import_node_path2.default.join(home, "Library", "Preferences");
|
|
110
110
|
break;
|
|
111
111
|
}
|
|
112
112
|
case "win32":
|
|
@@ -115,28 +115,28 @@ function getConfigDir() {
|
|
|
115
115
|
return;
|
|
116
116
|
}
|
|
117
117
|
function getDefaultOsuDataDir() {
|
|
118
|
-
return
|
|
118
|
+
return import_node_path2.default.join(getDataDir() ?? ".", "osu");
|
|
119
119
|
}
|
|
120
120
|
function getDefaultConfigPath(appName = "osu-play") {
|
|
121
|
-
return
|
|
121
|
+
return import_node_path2.default.join(getConfigDir() ?? ".", appName);
|
|
122
122
|
}
|
|
123
123
|
function hashedFilePath(hash, osuDataDir = getDefaultOsuDataDir()) {
|
|
124
|
-
return
|
|
124
|
+
return import_node_path2.default.join(osuDataDir, "files", hash.slice(0, 1), hash.slice(0, 2), hash);
|
|
125
125
|
}
|
|
126
126
|
function getRealmDBPath(legacyAppConfigDirOrOptions = {}, maybeOptions = {}) {
|
|
127
127
|
const options = typeof legacyAppConfigDirOrOptions === "string" ? maybeOptions : legacyAppConfigDirOrOptions;
|
|
128
128
|
const osuDataDir = options.osuDataDir ?? getDefaultOsuDataDir();
|
|
129
|
-
const osuDBPath =
|
|
130
|
-
if (!
|
|
129
|
+
const osuDBPath = import_node_path2.default.join(osuDataDir, "client.realm");
|
|
130
|
+
if (!import_node_fs2.existsSync(osuDBPath)) {
|
|
131
131
|
console.log(`[getRealmDBPath]: ${osuDBPath} not found`);
|
|
132
132
|
return null;
|
|
133
133
|
}
|
|
134
134
|
return osuDBPath;
|
|
135
135
|
}
|
|
136
|
-
var
|
|
136
|
+
var import_node_path2, import_node_fs2, import_node_child_process;
|
|
137
137
|
var init_mod = __esm(() => {
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
import_node_path2 = __toESM(require("node:path"));
|
|
139
|
+
import_node_fs2 = require("node:fs");
|
|
140
140
|
import_node_child_process = require("node:child_process");
|
|
141
141
|
});
|
|
142
142
|
|
|
@@ -529,12 +529,615 @@ var init_mod3 = __esm(() => {
|
|
|
529
529
|
});
|
|
530
530
|
|
|
531
531
|
// src/core/cli/main.ts
|
|
532
|
-
var
|
|
533
|
-
var
|
|
532
|
+
var import_node_path4 = __toESM(require("node:path"));
|
|
533
|
+
var import_node_fs4 = require("node:fs");
|
|
534
534
|
var import_pi_tui2 = require("@earendil-works/pi-tui");
|
|
535
535
|
var import_yargs = __toESM(require("yargs/yargs"));
|
|
536
536
|
var import_helpers = require("yargs/helpers");
|
|
537
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
|
+
|
|
538
1141
|
// src/core/playlist/mod.ts
|
|
539
1142
|
init_mod();
|
|
540
1143
|
function uniqueParts(parts) {
|
|
@@ -579,13 +1182,124 @@ function buildPlaylist(beatmapSets, osuDataDir) {
|
|
|
579
1182
|
return playlist;
|
|
580
1183
|
}
|
|
581
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
|
+
|
|
582
1296
|
// src/core/player/mpv.ts
|
|
583
1297
|
var import_node_child_process2 = require("node:child_process");
|
|
584
|
-
var
|
|
1298
|
+
var import_node_fs3 = require("node:fs");
|
|
585
1299
|
var import_node_net = __toESM(require("node:net"));
|
|
586
1300
|
var import_node_os = __toESM(require("node:os"));
|
|
587
|
-
var
|
|
588
|
-
var
|
|
1301
|
+
var import_node_path3 = __toESM(require("node:path"));
|
|
1302
|
+
var import_promises3 = require("node:timers/promises");
|
|
589
1303
|
function getErrorCode(error) {
|
|
590
1304
|
if (error && typeof error === "object" && "code" in error) {
|
|
591
1305
|
const { code } = error;
|
|
@@ -606,7 +1320,7 @@ function createSocketPath() {
|
|
|
606
1320
|
if (process.platform === "win32") {
|
|
607
1321
|
return `\\\\.\\pipe\\${id}`;
|
|
608
1322
|
}
|
|
609
|
-
return
|
|
1323
|
+
return import_node_path3.default.join(import_node_os.default.tmpdir(), `${id}.sock`);
|
|
610
1324
|
}
|
|
611
1325
|
function isControlText(value) {
|
|
612
1326
|
return [...value].some((character) => {
|
|
@@ -786,7 +1500,7 @@ ${detail}` : "";
|
|
|
786
1500
|
throw error;
|
|
787
1501
|
}
|
|
788
1502
|
}
|
|
789
|
-
await
|
|
1503
|
+
await import_promises3.setTimeout(50);
|
|
790
1504
|
}
|
|
791
1505
|
throw new Error(this.formatStartupError("Timed out waiting for mpv IPC to become ready."));
|
|
792
1506
|
}
|
|
@@ -963,7 +1677,7 @@ ${detail}` : "";
|
|
|
963
1677
|
new Promise((resolve) => {
|
|
964
1678
|
processHandle.once("exit", () => resolve());
|
|
965
1679
|
}),
|
|
966
|
-
|
|
1680
|
+
import_promises3.setTimeout(1000)
|
|
967
1681
|
]);
|
|
968
1682
|
if (processHandle.exitCode === null) {
|
|
969
1683
|
processHandle.kill("SIGKILL");
|
|
@@ -975,8 +1689,8 @@ ${detail}` : "";
|
|
|
975
1689
|
if (process.platform === "win32") {
|
|
976
1690
|
return;
|
|
977
1691
|
}
|
|
978
|
-
if (
|
|
979
|
-
|
|
1692
|
+
if (import_node_fs3.existsSync(this.socketPath)) {
|
|
1693
|
+
import_node_fs3.rmSync(this.socketPath, { force: true });
|
|
980
1694
|
}
|
|
981
1695
|
}
|
|
982
1696
|
formatStartupError(message) {
|
|
@@ -1030,10 +1744,13 @@ class PlaylistPlayerSession {
|
|
|
1030
1744
|
loop;
|
|
1031
1745
|
deleteTrack;
|
|
1032
1746
|
playlist;
|
|
1747
|
+
random;
|
|
1033
1748
|
reloadPlaylist;
|
|
1034
1749
|
revealTrack;
|
|
1035
1750
|
searchQuery = "";
|
|
1036
1751
|
selectedIndex = 0;
|
|
1752
|
+
shuffle;
|
|
1753
|
+
shuffleOrder = [];
|
|
1037
1754
|
operationQueue = Promise.resolve();
|
|
1038
1755
|
unsubscribeBackend;
|
|
1039
1756
|
constructor(playlist, backend, options = {}) {
|
|
@@ -1041,8 +1758,13 @@ class PlaylistPlayerSession {
|
|
|
1041
1758
|
this.playlist = playlist;
|
|
1042
1759
|
this.deleteTrack = options.deleteTrack;
|
|
1043
1760
|
this.loop = options.loop ?? false;
|
|
1761
|
+
this.random = options.random ?? Math.random;
|
|
1044
1762
|
this.reloadPlaylist = options.reloadPlaylist;
|
|
1045
1763
|
this.revealTrack = options.revealTrack;
|
|
1764
|
+
this.shuffle = options.shuffle ?? false;
|
|
1765
|
+
if (this.shuffle) {
|
|
1766
|
+
this.resetShuffleOrder(this.selectedIndex);
|
|
1767
|
+
}
|
|
1046
1768
|
this.unsubscribeBackend = backend.subscribe((event) => {
|
|
1047
1769
|
this.handleBackendEvent(event);
|
|
1048
1770
|
});
|
|
@@ -1060,6 +1782,7 @@ class PlaylistPlayerSession {
|
|
|
1060
1782
|
playlist: this.playlist,
|
|
1061
1783
|
searchQuery: this.searchQuery,
|
|
1062
1784
|
selectedIndex: this.selectedIndex,
|
|
1785
|
+
shuffle: this.shuffle,
|
|
1063
1786
|
status: backendSnapshot.status,
|
|
1064
1787
|
timePositionSeconds: backendSnapshot.timePositionSeconds
|
|
1065
1788
|
};
|
|
@@ -1134,30 +1857,43 @@ class PlaylistPlayerSession {
|
|
|
1134
1857
|
this.loop = !this.loop;
|
|
1135
1858
|
this.emit();
|
|
1136
1859
|
}
|
|
1860
|
+
toggleShuffle() {
|
|
1861
|
+
this.shuffle = !this.shuffle;
|
|
1862
|
+
if (this.shuffle) {
|
|
1863
|
+
this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
|
|
1864
|
+
}
|
|
1865
|
+
this.emit();
|
|
1866
|
+
}
|
|
1137
1867
|
appendSearchQuery(text) {
|
|
1138
|
-
if (!text)
|
|
1868
|
+
if (!text)
|
|
1139
1869
|
return;
|
|
1140
|
-
}
|
|
1141
1870
|
this.searchQuery += text;
|
|
1142
1871
|
this.syncSelectionToSearch();
|
|
1143
1872
|
}
|
|
1144
1873
|
deleteSearchCharacter() {
|
|
1145
|
-
if (!this.searchQuery)
|
|
1874
|
+
if (!this.searchQuery)
|
|
1146
1875
|
return;
|
|
1147
|
-
}
|
|
1148
1876
|
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
1149
1877
|
this.syncSelectionToSearch();
|
|
1150
1878
|
}
|
|
1879
|
+
deleteSearchWord() {
|
|
1880
|
+
if (!this.searchQuery)
|
|
1881
|
+
return;
|
|
1882
|
+
this.searchQuery = this.searchQuery.replace(/\s*\S+\s*$/, "");
|
|
1883
|
+
this.syncSelectionToSearch();
|
|
1884
|
+
}
|
|
1151
1885
|
clearSearch() {
|
|
1152
|
-
if (!this.searchQuery)
|
|
1886
|
+
if (!this.searchQuery)
|
|
1153
1887
|
return;
|
|
1154
|
-
}
|
|
1155
1888
|
this.searchQuery = "";
|
|
1156
1889
|
this.emit();
|
|
1157
1890
|
}
|
|
1158
1891
|
async playSelected() {
|
|
1159
1892
|
const selectedIndex = this.selectedIndex;
|
|
1160
1893
|
await this.enqueueOperation(async () => {
|
|
1894
|
+
if (this.shuffle) {
|
|
1895
|
+
this.resetShuffleOrder(selectedIndex);
|
|
1896
|
+
}
|
|
1161
1897
|
await this.playIndex(selectedIndex);
|
|
1162
1898
|
});
|
|
1163
1899
|
}
|
|
@@ -1274,6 +2010,9 @@ class PlaylistPlayerSession {
|
|
|
1274
2010
|
const nextCurrentIndex = this.playlist.findIndex((playlistTrack) => playlistTrack.hash === currentTrack.hash);
|
|
1275
2011
|
this.currentIndex = nextCurrentIndex === -1 ? null : nextCurrentIndex;
|
|
1276
2012
|
}
|
|
2013
|
+
if (this.shuffle) {
|
|
2014
|
+
this.resetShuffleOrder(this.currentIndex ?? this.selectedIndex);
|
|
2015
|
+
}
|
|
1277
2016
|
this.emit();
|
|
1278
2017
|
} catch (error) {
|
|
1279
2018
|
this.reportError(error);
|
|
@@ -1319,6 +2058,19 @@ class PlaylistPlayerSession {
|
|
|
1319
2058
|
return null;
|
|
1320
2059
|
}
|
|
1321
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
|
+
}
|
|
1322
2074
|
const nextIndex = baseIndex + delta;
|
|
1323
2075
|
const shouldWrap = wrap || this.loop;
|
|
1324
2076
|
if (nextIndex < 0) {
|
|
@@ -1329,6 +2081,17 @@ class PlaylistPlayerSession {
|
|
|
1329
2081
|
}
|
|
1330
2082
|
return nextIndex;
|
|
1331
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
|
+
}
|
|
1332
2095
|
handleBackendEvent(event) {
|
|
1333
2096
|
switch (event.type) {
|
|
1334
2097
|
case "state":
|
|
@@ -1543,6 +2306,10 @@ class PlaylistPlayerScreen {
|
|
|
1543
2306
|
this.session.toggleLoop();
|
|
1544
2307
|
return;
|
|
1545
2308
|
}
|
|
2309
|
+
if (import_pi_tui.matchesKey(data, "x")) {
|
|
2310
|
+
this.session.toggleShuffle();
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
1546
2313
|
if (import_pi_tui.matchesKey(data, "o")) {
|
|
1547
2314
|
this.session.revealSelectedTrack();
|
|
1548
2315
|
return;
|
|
@@ -1580,7 +2347,7 @@ class PlaylistPlayerScreen {
|
|
|
1580
2347
|
} else if (this.searchMode || this.snapshot.searchQuery) {
|
|
1581
2348
|
lines.push(import_pi_tui.truncateToWidth(style(`/ ${this.snapshot.searchQuery}${this.searchMode ? "▏" : ""}`, CYAN), width));
|
|
1582
2349
|
} else {
|
|
1583
|
-
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));
|
|
1584
2351
|
}
|
|
1585
2352
|
if (playlist.length === 0) {
|
|
1586
2353
|
lines.push(import_pi_tui.truncateToWidth("No tracks were found in your osu!lazer library.", width));
|
|
@@ -1606,7 +2373,7 @@ class PlaylistPlayerScreen {
|
|
|
1606
2373
|
while (lines.length < viewportHeight - 1) {
|
|
1607
2374
|
lines.push("");
|
|
1608
2375
|
}
|
|
1609
|
-
lines.push(import_pi_tui.truncateToWidth(style(this.searchMode ? "type to filter · ↑/↓ results · enter play ·
|
|
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));
|
|
1610
2377
|
return lines;
|
|
1611
2378
|
}
|
|
1612
2379
|
armPendingGoToTop() {
|
|
@@ -1659,6 +2426,10 @@ class PlaylistPlayerScreen {
|
|
|
1659
2426
|
this.session.deleteSearchCharacter();
|
|
1660
2427
|
return;
|
|
1661
2428
|
}
|
|
2429
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("w"))) {
|
|
2430
|
+
this.session.deleteSearchWord();
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
1662
2433
|
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("u"))) {
|
|
1663
2434
|
this.session.clearSearch();
|
|
1664
2435
|
return;
|
|
@@ -1701,33 +2472,17 @@ class PlaylistPlayerScreen {
|
|
|
1701
2472
|
|
|
1702
2473
|
// src/core/cli/main.ts
|
|
1703
2474
|
init_mod();
|
|
1704
|
-
function formatRealmLoadError(error) {
|
|
1705
|
-
return [
|
|
1706
|
-
"Realm native bindings could not be loaded.",
|
|
1707
|
-
`Current Node runtime: ${process.version}.`,
|
|
1708
|
-
"To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
|
|
1709
|
-
"If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
|
|
1710
|
-
`Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
1711
|
-
].join(`
|
|
1712
|
-
`);
|
|
1713
|
-
}
|
|
1714
|
-
async function loadRealmDependencies() {
|
|
1715
|
-
try {
|
|
1716
|
-
const [{ default: Realm8 }, lazerModule] = await Promise.all([
|
|
1717
|
-
import("realm"),
|
|
1718
|
-
Promise.resolve().then(() => (init_mod3(), exports_mod2))
|
|
1719
|
-
]);
|
|
1720
|
-
return {
|
|
1721
|
-
Realm: Realm8,
|
|
1722
|
-
...lazerModule
|
|
1723
|
-
};
|
|
1724
|
-
} catch (error) {
|
|
1725
|
-
throw new Error(formatRealmLoadError(error), { cause: error });
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
2475
|
function getArgs() {
|
|
1729
|
-
return import_yargs.default(import_helpers.hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps
|
|
1730
|
-
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", {
|
|
1731
2486
|
type: "boolean",
|
|
1732
2487
|
default: false,
|
|
1733
2488
|
alias: "r",
|
|
@@ -1749,44 +2504,38 @@ Usage: $0 [options]`).option("reload", {
|
|
|
1749
2504
|
default: false,
|
|
1750
2505
|
alias: "l",
|
|
1751
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;
|
|
1752
2525
|
}).alias("help", "h").help().parse();
|
|
1753
2526
|
}
|
|
1754
|
-
async function createPlaylist(osuDataDir) {
|
|
1755
|
-
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
1756
|
-
if (!realmDBPath) {
|
|
1757
|
-
throw new Error("Realm DB not found");
|
|
1758
|
-
}
|
|
1759
|
-
const {
|
|
1760
|
-
Realm: Realm8,
|
|
1761
|
-
formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
|
|
1762
|
-
getBeatmapSets: getBeatmapSets2,
|
|
1763
|
-
getLazerDB: getLazerDB2,
|
|
1764
|
-
inspectLazerSchema: inspectLazerSchema2
|
|
1765
|
-
} = await loadRealmDependencies();
|
|
1766
|
-
Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
|
|
1767
|
-
const realm = await getLazerDB2(realmDBPath);
|
|
1768
|
-
try {
|
|
1769
|
-
const schemaReport = inspectLazerSchema2(realm);
|
|
1770
|
-
if (!schemaReport.compatible) {
|
|
1771
|
-
throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
|
|
1772
|
-
}
|
|
1773
|
-
return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
|
|
1774
|
-
} finally {
|
|
1775
|
-
realm.close();
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
2527
|
async function deleteTrackFromCollection(track, osuDataDir) {
|
|
1779
|
-
|
|
1780
|
-
await deleteBeatmapSet2(track, osuDataDir);
|
|
2528
|
+
await deleteBeatmapSetFromLazer(track, osuDataDir);
|
|
1781
2529
|
}
|
|
1782
|
-
async function runTuiPlayer(playlist, osuDataDir, loop) {
|
|
2530
|
+
async function runTuiPlayer(playlist, osuDataDir, loop, shuffle) {
|
|
1783
2531
|
const { revealFile: revealFile2 } = await Promise.resolve().then(() => (init_mod(), exports_mod));
|
|
1784
2532
|
const backend = new MpvPlayerBackend;
|
|
1785
2533
|
const session = new PlaylistPlayerSession(playlist, backend, {
|
|
1786
2534
|
deleteTrack: (track) => deleteTrackFromCollection(track, osuDataDir),
|
|
1787
2535
|
loop,
|
|
1788
|
-
reloadPlaylist: () =>
|
|
1789
|
-
revealTrack: (track) => revealFile2(track.path)
|
|
2536
|
+
reloadPlaylist: () => loadPlaylistFromLazer(osuDataDir),
|
|
2537
|
+
revealTrack: (track) => revealFile2(track.path),
|
|
2538
|
+
shuffle
|
|
1790
2539
|
});
|
|
1791
2540
|
const terminal = new import_pi_tui2.ProcessTerminal;
|
|
1792
2541
|
const tui = new import_pi_tui2.TUI(terminal);
|
|
@@ -1817,6 +2566,30 @@ async function runTuiPlayer(playlist, osuDataDir, loop) {
|
|
|
1817
2566
|
await session.dispose();
|
|
1818
2567
|
}
|
|
1819
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
|
+
}
|
|
1820
2593
|
async function main() {
|
|
1821
2594
|
const argv = await getArgs();
|
|
1822
2595
|
if (argv.reload) {
|
|
@@ -1825,16 +2598,20 @@ async function main() {
|
|
|
1825
2598
|
if (argv.configDir) {
|
|
1826
2599
|
console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
|
|
1827
2600
|
}
|
|
1828
|
-
|
|
2601
|
+
if (argv.api) {
|
|
2602
|
+
await runApiMode(argv.osuDataDir, argv.apiPort);
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
const playlist = await loadPlaylistFromLazer(argv.osuDataDir);
|
|
1829
2606
|
if (argv.exportPlaylist) {
|
|
1830
2607
|
const playlistContents = playlist.map((track) => track.path).join(`
|
|
1831
2608
|
`);
|
|
1832
|
-
|
|
2609
|
+
import_node_fs4.writeFileSync(argv.exportPlaylist, playlistContents);
|
|
1833
2610
|
console.log(`[INFO] Exported ${playlist.length} tracks to ${argv.exportPlaylist}.`);
|
|
1834
|
-
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.`);
|
|
1835
2612
|
return;
|
|
1836
2613
|
}
|
|
1837
|
-
await runTuiPlayer(playlist, argv.osuDataDir, argv.loop);
|
|
2614
|
+
await runTuiPlayer(playlist, argv.osuDataDir, argv.loop, argv.shuffle);
|
|
1838
2615
|
}
|
|
1839
2616
|
|
|
1840
2617
|
// src/cli.ts
|
|
@@ -1845,4 +2622,4 @@ main().then(() => {
|
|
|
1845
2622
|
process.exit(1);
|
|
1846
2623
|
});
|
|
1847
2624
|
|
|
1848
|
-
//# debugId=
|
|
2625
|
+
//# debugId=B22693114D23F24364756E2164756E21
|