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