Compare commits
12 Commits
0916de60f3
...
3.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c9916c69d | |||
| cfd023be69 | |||
| 719359b940 | |||
| f4d0bd7ca0 | |||
| 26a13c27d6 | |||
| cfe5063828 | |||
| 69742ce2d9 | |||
| 5b3787f19f | |||
| 9b0f6e2123 | |||
| 2d4eae9676 | |||
| 848c4c2b55 | |||
| a6ca763730 |
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/.env
|
||||
ios/build/
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/
|
||||
fastlane/test_output/
|
||||
6
fastlane/Appfile
Normal file
6
fastlane/Appfile
Normal file
@@ -0,0 +1,6 @@
|
||||
apple_id("james.magahern@mac.com")
|
||||
app_identifier("net.buzzert.QueueCube")
|
||||
team_id("DQQH5H6GBD")
|
||||
team_name("James Magahern")
|
||||
itc_team_id("127764897")
|
||||
itc_team_name("James Magahern")
|
||||
129
fastlane/Fastfile
Normal file
129
fastlane/Fastfile
Normal file
@@ -0,0 +1,129 @@
|
||||
require "fileutils"
|
||||
require "open3"
|
||||
require "shellwords"
|
||||
require "tempfile"
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
BUNDLE_IDENTIFIER = "net.buzzert.QueueCube"
|
||||
DEVELOPMENT_TEAM = "DQQH5H6GBD"
|
||||
APP_ROOT = File.expand_path("..", File.expand_path(__dir__))
|
||||
IOS_PROJECT_DIR = File.join(APP_ROOT, "ios")
|
||||
XCODE_PROJECT = File.join(IOS_PROJECT_DIR, "QueueCube.xcodeproj")
|
||||
SCHEME = "QueueCube"
|
||||
ARCHIVE_PATH = File.join(IOS_PROJECT_DIR, "build", "#{SCHEME}.xcarchive")
|
||||
EXPORT_PATH = File.join(IOS_PROJECT_DIR, "build", "upload")
|
||||
|
||||
def shell_command(*parts)
|
||||
parts.flatten.map { |part| part.to_s.shellescape }.join(" ")
|
||||
end
|
||||
|
||||
def archive_path
|
||||
ARCHIVE_PATH
|
||||
end
|
||||
|
||||
def export_path
|
||||
EXPORT_PATH
|
||||
end
|
||||
|
||||
def git_output(*args)
|
||||
stdout, stderr, status = Open3.capture3("git", *args, chdir: APP_ROOT)
|
||||
UI.user_error!("git #{args.join(' ')} failed: #{stderr.strip}") unless status.success?
|
||||
|
||||
stdout.strip
|
||||
end
|
||||
|
||||
def app_version
|
||||
tag = git_output("describe", "--tags", "--abbrev=0")
|
||||
version = tag.sub(/\Av/, "")
|
||||
unless version.match?(/\A\d+(?:\.\d+){0,2}\z/)
|
||||
UI.user_error!("Latest git tag #{tag.inspect} is not a valid App Store version. Use a tag like 1.5.2 or v1.5.2.")
|
||||
end
|
||||
|
||||
version
|
||||
end
|
||||
|
||||
def build_number
|
||||
git_output("rev-list", "--count", "HEAD")
|
||||
end
|
||||
|
||||
def upload_export_options
|
||||
<<~PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>teamID</key>
|
||||
<string>#{DEVELOPMENT_TEAM}</string>
|
||||
<key>manageAppVersionAndBuildNumber</key>
|
||||
<false/>
|
||||
<key>stripSwiftSymbols</key>
|
||||
<true/>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
end
|
||||
|
||||
platform :ios do
|
||||
desc "Build QueueCube for iOS and upload the archive to TestFlight"
|
||||
lane :beta do
|
||||
version = app_version
|
||||
build = build_number
|
||||
UI.message("Using QueueCube version #{version} (build #{build}) from git")
|
||||
|
||||
FileUtils.rm_rf(archive_path)
|
||||
FileUtils.rm_rf(export_path)
|
||||
|
||||
Dir.chdir(IOS_PROJECT_DIR) do
|
||||
sh(shell_command(
|
||||
"xcodebuild",
|
||||
"-project", XCODE_PROJECT,
|
||||
"-scheme", SCHEME,
|
||||
"-configuration", "Release",
|
||||
"-destination", "generic/platform=iOS",
|
||||
"-archivePath", archive_path,
|
||||
"-allowProvisioningUpdates",
|
||||
"clean",
|
||||
"archive",
|
||||
"DEVELOPMENT_TEAM=#{DEVELOPMENT_TEAM}",
|
||||
"PRODUCT_BUNDLE_IDENTIFIER=#{BUNDLE_IDENTIFIER}",
|
||||
"MARKETING_VERSION=#{version}",
|
||||
"CURRENT_PROJECT_VERSION=#{build}"
|
||||
))
|
||||
end
|
||||
|
||||
export_options = Tempfile.new(["queuecube-export-options", ".plist"])
|
||||
export_options.write(upload_export_options)
|
||||
export_options.close
|
||||
|
||||
FileUtils.rm_rf(export_path)
|
||||
Dir.chdir(IOS_PROJECT_DIR) do
|
||||
sh(shell_command(
|
||||
"xcodebuild",
|
||||
"-exportArchive",
|
||||
"-archivePath", archive_path,
|
||||
"-exportPath", export_path,
|
||||
"-exportOptionsPlist", export_options.path,
|
||||
"-allowProvisioningUpdates"
|
||||
))
|
||||
end
|
||||
|
||||
ipa_path = Dir[File.join(export_path, "*.ipa")].first
|
||||
UI.user_error!("No IPA found in #{export_path}") unless ipa_path
|
||||
|
||||
upload_to_testflight(
|
||||
app_identifier: BUNDLE_IDENTIFIER,
|
||||
ipa: ipa_path,
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
ensure
|
||||
export_options&.unlink
|
||||
end
|
||||
end
|
||||
32
fastlane/README.md
Normal file
32
fastlane/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## iOS
|
||||
|
||||
### ios beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios beta
|
||||
```
|
||||
|
||||
Build QueueCube for iOS and upload the archive to TestFlight
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
@@ -269,7 +269,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -284,7 +284,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -305,7 +305,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
CURRENT_PROJECT_VERSION = 13;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -320,7 +320,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5;
|
||||
MARKETING_VERSION = 1.5.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
|
||||
@@ -187,10 +187,11 @@ struct MediaItemCell: View
|
||||
Spacer()
|
||||
}
|
||||
.padding([.top, .bottom], 4.0)
|
||||
.frame(minHeight: 44.0)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
if let playbackError {
|
||||
if playbackError != nil {
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ interface PendingCommand {
|
||||
reject: (reason: any) => void;
|
||||
}
|
||||
|
||||
enum ObservedProperty {
|
||||
MediaTitle = 1,
|
||||
Metadata = 2,
|
||||
}
|
||||
|
||||
enum UserEvent {
|
||||
PlaylistUpdate = "playlist_update",
|
||||
NowPlayingUpdate = "now_playing_update",
|
||||
@@ -61,6 +66,8 @@ export class MediaPlayer {
|
||||
private playbackErrors: Map<string, string> = new Map();
|
||||
private currentFile: string | null = null;
|
||||
private lastLoadCandidate: string | null = null;
|
||||
private currentMediaTitle: string | null = null;
|
||||
private currentPlaybackMetadata: Record<string, string> = {};
|
||||
|
||||
constructor() {
|
||||
this.socket = this.tryRespawnPlayerProcess();
|
||||
@@ -114,10 +121,9 @@ export class MediaPlayer {
|
||||
const socketFilename = Math.random().toString(36).substring(2, 10);
|
||||
const socketPath = `/tmp/mpv-${socketFilename}`;
|
||||
const enableVideo = process.env.ENABLE_VIDEO || false;
|
||||
const ytdlFormat = process.env.MPV_YTDL_FORMAT;
|
||||
const logfilePath = `/tmp/mpv-logfile.txt`;
|
||||
|
||||
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
||||
this.playerProcess = spawn("mpv", [
|
||||
const playerArgs = [
|
||||
"--video=" + (enableVideo ? "auto" : "no"),
|
||||
"--fullscreen",
|
||||
"--no-terminal",
|
||||
@@ -125,25 +131,38 @@ export class MediaPlayer {
|
||||
"--input-ipc-server=" + socketPath,
|
||||
"--log-file=" + logfilePath,
|
||||
"--msg-level=all=v"
|
||||
]);
|
||||
];
|
||||
|
||||
if (ytdlFormat) {
|
||||
playerArgs.push("--ytdl-format=" + ytdlFormat);
|
||||
}
|
||||
|
||||
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
||||
this.playerProcess = spawn("mpv", playerArgs);
|
||||
|
||||
|
||||
let socketReady!: (s: Socket) => void;
|
||||
let socketPromise = new Promise<Socket>(resolve => {
|
||||
let socketFailed!: (reason?: unknown) => void;
|
||||
let socketPromise = new Promise<Socket>((resolve, reject) => {
|
||||
socketReady = resolve;
|
||||
socketFailed = reject;
|
||||
});
|
||||
|
||||
this.playerProcess.on("spawn", () => {
|
||||
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
||||
setTimeout(() => {
|
||||
let socket = this.connectToSocket(socketPath);
|
||||
socketReady(socket);
|
||||
}, 500);
|
||||
this.connectToSocket(socketPath)
|
||||
.then(socket => socketReady(socket))
|
||||
.catch((error: unknown) => {
|
||||
console.error(`Failed to connect to mpv socket @ ${socketPath}:`, error);
|
||||
console.log("Continuing without mpv player...");
|
||||
socketFailed(error);
|
||||
});
|
||||
});
|
||||
|
||||
this.playerProcess.on("error", (error) => {
|
||||
this.playerProcess.on("error", (error: unknown) => {
|
||||
console.error("Player process error:", error);
|
||||
console.log("Continuing without mpv player...");
|
||||
socketFailed(error);
|
||||
});
|
||||
|
||||
return socketPromise;
|
||||
@@ -154,11 +173,15 @@ export class MediaPlayer {
|
||||
.then((response) => {
|
||||
// Enhance playlist items with metadata
|
||||
const playlist = response.data as PlaylistItem[];
|
||||
return playlist.map((item: PlaylistItem) => ({
|
||||
...item,
|
||||
metadata: this.metadata.get(item.filename) || {},
|
||||
playbackError: this.playbackErrors.get(item.filename)
|
||||
}));
|
||||
return playlist.map((item: PlaylistItem) => {
|
||||
const enhancedItem = {
|
||||
...item,
|
||||
metadata: this.metadata.get(item.filename) || {},
|
||||
playbackError: this.playbackErrors.get(item.filename)
|
||||
};
|
||||
|
||||
return item.current ? this.withCurrentDynamicTitle(enhancedItem) : enhancedItem;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,29 +190,37 @@ export class MediaPlayer {
|
||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||
const fetchMediaTitle = async (): Promise<string | null> => {
|
||||
try {
|
||||
return (await this.writeCommand("get_property", ["media-title"])).data;
|
||||
const mediaTitle = (await this.writeCommand("get_property", ["media-title"])).data;
|
||||
this.currentMediaTitle = typeof mediaTitle === "string" ? mediaTitle : null;
|
||||
return this.currentMediaTitle;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const mediaTitle = await fetchMediaTitle();
|
||||
|
||||
if (currentlyPlayingSong !== undefined) {
|
||||
const dynamicTitle = this.getCurrentDynamicTitle(currentlyPlayingSong);
|
||||
if (dynamicTitle) {
|
||||
return this.withCurrentDynamicTitle(currentlyPlayingSong, dynamicTitle);
|
||||
}
|
||||
|
||||
// Use media title if we don't have a title
|
||||
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
||||
return {
|
||||
...currentlyPlayingSong,
|
||||
title: await fetchMediaTitle() || currentlyPlayingSong.filename
|
||||
title: mediaTitle || currentlyPlayingSong.filename
|
||||
};
|
||||
}
|
||||
|
||||
return currentlyPlayingSong;
|
||||
}
|
||||
|
||||
const mediaTitle = await fetchMediaTitle() || "";
|
||||
return {
|
||||
id: 0,
|
||||
filename: mediaTitle,
|
||||
title: mediaTitle
|
||||
filename: mediaTitle || "",
|
||||
title: mediaTitle || ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,7 +428,7 @@ export class MediaPlayer {
|
||||
socket.write(commandObject + '\n');
|
||||
} catch (e: any) {
|
||||
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
|
||||
this.tryRespawnPlayerProcess();
|
||||
this.socket = this.tryRespawnPlayerProcess();
|
||||
}
|
||||
|
||||
// Add timeout to prevent hanging promises
|
||||
@@ -436,12 +467,52 @@ export class MediaPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
private connectToSocket(path: string): Socket {
|
||||
let socket = new Socket();
|
||||
socket.connect(path);
|
||||
socket.on("data", data => this.receiveData(data.toString()));
|
||||
private connectToSocket(path: string): Promise<Socket> {
|
||||
const retryDelayMs = 100;
|
||||
const maxAttempts = 50;
|
||||
|
||||
return socket;
|
||||
return new Promise((resolve, reject) => {
|
||||
type SocketError = Error & { code?: string };
|
||||
|
||||
const attemptConnection = (attempt: number) => {
|
||||
const socket = new Socket();
|
||||
|
||||
const onError = (error: SocketError) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
|
||||
const shouldRetry = (error.code === "ENOENT" || error.code === "ECONNREFUSED") && attempt < maxAttempts;
|
||||
if (shouldRetry) {
|
||||
setTimeout(() => attemptConnection(attempt + 1), retryDelayMs);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(error);
|
||||
};
|
||||
|
||||
socket.once("connect", () => {
|
||||
socket.removeListener("error", onError);
|
||||
socket.on("data", (data: unknown) => this.receiveData(String(data)));
|
||||
socket.on("error", (error: unknown) => {
|
||||
console.error("MPV socket error:", error);
|
||||
});
|
||||
resolve(socket);
|
||||
this.registerMpvObservers().catch((error: unknown) => {
|
||||
console.error("Failed to register mpv observers:", error);
|
||||
});
|
||||
});
|
||||
|
||||
socket.once("error", onError);
|
||||
socket.connect(path);
|
||||
};
|
||||
|
||||
attemptConnection(1);
|
||||
});
|
||||
}
|
||||
|
||||
private async registerMpvObservers() {
|
||||
await this.writeCommand("observe_property", [ObservedProperty.MediaTitle, "media-title"]);
|
||||
await this.writeCommand("observe_property", [ObservedProperty.Metadata, "metadata"]);
|
||||
}
|
||||
|
||||
private handleEvent(event: string, data: any) {
|
||||
@@ -484,6 +555,11 @@ export class MediaPlayer {
|
||||
this.currentFile = file;
|
||||
this.playbackErrors.delete(file);
|
||||
}
|
||||
|
||||
this.currentMediaTitle = null;
|
||||
this.currentPlaybackMetadata = {};
|
||||
} else if (response.event === "property-change") {
|
||||
this.handlePropertyChange(response);
|
||||
} else if (response.event === "end-file" && response.reason === "error") {
|
||||
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
|
||||
const errorMessage = response.error || response["file-error"] || "Unknown playback error";
|
||||
@@ -505,4 +581,143 @@ export class MediaPlayer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handlePropertyChange(response: any) {
|
||||
const previousTitle = this.getCurrentDynamicTitle();
|
||||
|
||||
if (response.id === ObservedProperty.MediaTitle || response.name === "media-title") {
|
||||
this.currentMediaTitle = typeof response.data === "string" ? response.data : null;
|
||||
} else if (response.id === ObservedProperty.Metadata || response.name === "metadata") {
|
||||
this.currentPlaybackMetadata = this.normalizeMpvMetadata(response.data);
|
||||
}
|
||||
|
||||
const currentTitle = this.getCurrentDynamicTitle();
|
||||
if (currentTitle !== previousTitle) {
|
||||
this.handleEvent(UserEvent.MetadataUpdate, {
|
||||
url: this.currentFile,
|
||||
title: currentTitle,
|
||||
metadata: this.currentPlaybackMetadata
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private withCurrentDynamicTitle(item: PlaylistItem, dynamicTitle: string | null = this.getCurrentDynamicTitle(item)): PlaylistItem {
|
||||
if (!dynamicTitle) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: dynamicTitle,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
title: dynamicTitle
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private getCurrentDynamicTitle(item?: PlaylistItem): string | null {
|
||||
if (this.currentMediaTitle && !this.isFallbackMediaTitle(this.currentMediaTitle, item?.filename)) {
|
||||
return this.currentMediaTitle.trim();
|
||||
}
|
||||
|
||||
return this.extractTitleFromMpvMetadata();
|
||||
}
|
||||
|
||||
private extractTitleFromMpvMetadata(): string | null {
|
||||
const titleKeys = new Set(["title", "icy-title", "icy_title", "streamtitle", "stream-title"]);
|
||||
|
||||
for (const key of Object.keys(this.currentPlaybackMetadata)) {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (!titleKeys.has(normalizedKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const title = this.parseStreamTitle(this.currentPlaybackMetadata[key]);
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseStreamTitle(value: string | undefined): string | null {
|
||||
const trimmedValue = value?.trim();
|
||||
if (!trimmedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const streamTitleMatch = trimmedValue.match(/StreamTitle='([^']*)'/i);
|
||||
return (streamTitleMatch?.[1] || trimmedValue).trim() || null;
|
||||
}
|
||||
|
||||
private normalizeMpvMetadata(data: unknown): Record<string, string> {
|
||||
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {};
|
||||
const rawMetadata = data as Record<string, unknown>;
|
||||
|
||||
for (const key of Object.keys(rawMetadata)) {
|
||||
const value = rawMetadata[key];
|
||||
if (value !== null && value !== undefined) {
|
||||
metadata[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private isFallbackMediaTitle(title: string, filename?: string): boolean {
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const fallbackCandidates = new Set<string>();
|
||||
if (filename) {
|
||||
this.addFallbackTitleCandidates(fallbackCandidates, filename);
|
||||
}
|
||||
|
||||
if (this.currentFile) {
|
||||
this.addFallbackTitleCandidates(fallbackCandidates, this.currentFile);
|
||||
}
|
||||
|
||||
return fallbackCandidates.has(normalizedTitle);
|
||||
}
|
||||
|
||||
private addFallbackTitleCandidates(candidates: Set<string>, value: string) {
|
||||
candidates.add(value);
|
||||
|
||||
try {
|
||||
candidates.add(decodeURIComponent(value));
|
||||
} catch {
|
||||
// Keep the original value if it is not URI-encoded.
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
this.addPathTitleCandidates(candidates, url.pathname);
|
||||
} catch {
|
||||
this.addPathTitleCandidates(candidates, value);
|
||||
}
|
||||
}
|
||||
|
||||
private addPathTitleCandidates(candidates: Set<string>, path: string) {
|
||||
const pathSegments = path.split("/").filter(Boolean);
|
||||
const lastPathSegment = pathSegments[pathSegments.length - 1];
|
||||
if (!lastPathSegment) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidates.add(lastPathSegment);
|
||||
|
||||
try {
|
||||
candidates.add(decodeURIComponent(lastPathSegment));
|
||||
} catch {
|
||||
// Keep the original segment if it is not URI-encoded.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
web/flake.lock
generated
6
web/flake.lock
generated
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1762977756,
|
||||
"narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=",
|
||||
"lastModified": 1779560665,
|
||||
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55",
|
||||
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
155
web/flake.nix
155
web/flake.nix
@@ -8,10 +8,76 @@
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
let
|
||||
mkQueuecube = pkgs: pkgs.buildNpmPackage {
|
||||
pname = "queuecube";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
# Skip the standard buildPhase and provide our own
|
||||
dontNpmBuild = true;
|
||||
buildPhase = ''
|
||||
# First install all dependencies
|
||||
npm install
|
||||
|
||||
# Then run the build with workspaces flag
|
||||
npm run build --workspaces
|
||||
'';
|
||||
|
||||
# Runtime dependencies
|
||||
buildInputs = with pkgs; [
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
];
|
||||
|
||||
# Create a wrapper script to ensure runtime deps are available
|
||||
postInstall = ''
|
||||
# Create the necessary directories
|
||||
mkdir -p $out/lib/node_modules/queuecube
|
||||
|
||||
# Copy the entire project with built files
|
||||
cp -r . $out/lib/node_modules/queuecube
|
||||
|
||||
# Install the frontend build to the backend dist directory
|
||||
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
|
||||
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
|
||||
|
||||
# Create bin directory if it doesn't exist
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Create executable script
|
||||
cat > $out/bin/queuecube <<EOF
|
||||
#!/bin/sh
|
||||
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
|
||||
EOF
|
||||
|
||||
# Make it executable
|
||||
chmod +x $out/bin/queuecube
|
||||
|
||||
# Wrap the program to include runtime deps in PATH
|
||||
wrapProgram $out/bin/queuecube \
|
||||
--prefix PATH : ${pkgs.lib.makeBinPath [
|
||||
pkgs.mpv
|
||||
pkgs.yt-dlp
|
||||
pkgs.pulseaudio
|
||||
]}
|
||||
'';
|
||||
|
||||
# Let buildNpmPackage handle npm package hash
|
||||
npmDepsHash = "sha256-kwbWqNqji0EcBeRuc/sqQUuGQkE+P8puLTfpAyRRzgY=";
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "NodeJS application with media playback capabilities";
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
# Define the NixOS module for the systemd service
|
||||
nixosModule = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.queuecube;
|
||||
package = mkQueuecube pkgs;
|
||||
in {
|
||||
options.services.queuecube = {
|
||||
enable = lib.mkEnableOption "QueueCube media player service";
|
||||
@@ -27,6 +93,18 @@
|
||||
default = false;
|
||||
description = "Enable video playback";
|
||||
};
|
||||
|
||||
enable_screenshare = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable screensharing";
|
||||
};
|
||||
|
||||
mpv_ytdl_format = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = "best";
|
||||
description = "yt-dlp format selector passed to mpv. Set to null to use mpv's default format selection.";
|
||||
};
|
||||
|
||||
store_path = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
@@ -65,7 +143,7 @@
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = {
|
||||
packages = [ self.packages.${pkgs.system}.queuecube ];
|
||||
packages = [ package ];
|
||||
};
|
||||
|
||||
systemd.user.services.queuecube = {
|
||||
@@ -74,7 +152,7 @@
|
||||
after = [ "pipewire.service" "pipewire-pulse.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${self.packages.${pkgs.system}.queuecube}/bin/queuecube";
|
||||
ExecStart = "${package}/bin/queuecube";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
@@ -109,9 +187,12 @@
|
||||
environment = {
|
||||
PORT = toString cfg.port;
|
||||
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
||||
ENABLE_SCREENSHARE = if cfg.enable_screenshare then "1" else "0";
|
||||
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
|
||||
INVIDIOUS_BASE_URL = cfg.invidious.url;
|
||||
STORE_PATH = cfg.store_path;
|
||||
} // lib.optionalAttrs (cfg.mpv_ytdl_format != null) {
|
||||
MPV_YTDL_FORMAT = cfg.mpv_ytdl_format;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -120,72 +201,7 @@
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Define the package using buildNpmPackage
|
||||
queuecube = pkgs.buildNpmPackage {
|
||||
pname = "queuecube";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
# Skip the standard buildPhase and provide our own
|
||||
dontNpmBuild = true;
|
||||
buildPhase = ''
|
||||
# First install all dependencies
|
||||
npm install
|
||||
|
||||
# Then run the build with workspaces flag
|
||||
npm run build --workspaces
|
||||
'';
|
||||
|
||||
# Runtime dependencies
|
||||
buildInputs = with pkgs; [
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
];
|
||||
|
||||
# Create a wrapper script to ensure runtime deps are available
|
||||
postInstall = ''
|
||||
# Create the necessary directories
|
||||
mkdir -p $out/lib/node_modules/queuecube
|
||||
|
||||
# Copy the entire project with built files
|
||||
cp -r . $out/lib/node_modules/queuecube
|
||||
|
||||
# Install the frontend build to the backend dist directory
|
||||
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
|
||||
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
|
||||
|
||||
# Create bin directory if it doesn't exist
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Create executable script
|
||||
cat > $out/bin/queuecube <<EOF
|
||||
#!/bin/sh
|
||||
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
|
||||
EOF
|
||||
|
||||
# Make it executable
|
||||
chmod +x $out/bin/queuecube
|
||||
|
||||
# Wrap the program to include runtime deps in PATH
|
||||
wrapProgram $out/bin/queuecube \
|
||||
--prefix PATH : ${pkgs.lib.makeBinPath [
|
||||
pkgs.mpv
|
||||
pkgs.yt-dlp
|
||||
pkgs.pulseaudio
|
||||
]}
|
||||
'';
|
||||
|
||||
# Let buildNpmPackage handle npm package hash
|
||||
npmDepsHash = "sha256-kwbWqNqji0EcBeRuc/sqQUuGQkE+P8puLTfpAyRRzgY=";
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "NodeJS application with media playback capabilities";
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
};
|
||||
queuecube = mkQueuecube pkgs;
|
||||
|
||||
in {
|
||||
packages = {
|
||||
@@ -201,8 +217,7 @@
|
||||
# Development environment
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_20
|
||||
nodePackages.npm
|
||||
nodejs
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
|
||||
Reference in New Issue
Block a user