discstation 0.1.18 → 0.1.21
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 +7 -5
- package/arduino/c6/DiscStation_C6.ino +7 -1
- package/arduino/v1/DiscStation.ino +4 -1
- package/docs/PLATFORM_SUPPORT.md +24 -5
- package/install-windows.ps1 +162 -18
- package/package.json +2 -1
- package/scripts/setup.mjs +1 -1
- package/src/discstation.py +522 -120
- package/src/discstation_burn.py +167 -8
- package/src/discstation_host.py +122 -3
- package/src/static/app.js +72 -0
- package/src/static/index.html +37 -3
- package/src/static/style.css +38 -0
- package/src/win/_json.ps1 +29 -0
- package/src/win/audio-toc.ps1 +43 -0
- package/src/win/burn-audio.ps1 +87 -0
- package/src/win/burn-data.ps1 +83 -0
- package/src/win/burn-image.ps1 +68 -0
- package/src/win/disc-info.ps1 +81 -0
- package/src/win/eject.ps1 +30 -0
- package/src/win/play-audio-cd.ps1 +94 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Read an audio CD's table of contents via IMAPI2 raw reader. Emits JSON:
|
|
2
|
+
# {"track_count":N,"leadout":L,"tracks":[o1,o2,...]} (frame offsets, +150)
|
|
3
|
+
# Usage: audio-toc.ps1 <drive e.g. D:>
|
|
4
|
+
param([Parameter(Mandatory = $true)] [string] $Drive)
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = "Stop"
|
|
7
|
+
. (Join-Path $PSScriptRoot "_json.ps1")
|
|
8
|
+
|
|
9
|
+
function Get-Recorder([string]$letter) {
|
|
10
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
11
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
12
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
13
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
14
|
+
foreach ($p in $rec.VolumePathNames) { if ($p -and $p.TrimEnd('\') -ieq $letter) { return $rec } }
|
|
15
|
+
}
|
|
16
|
+
throw "No optical recorder for $letter"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
$rec = Get-Recorder $Drive
|
|
20
|
+
$raw = New-Object -ComObject "IMAPI2.MsftDiscFormat2RawCD"
|
|
21
|
+
$raw.Recorder = $rec
|
|
22
|
+
$raw.ClientName = "DiscStation"
|
|
23
|
+
|
|
24
|
+
$toc = $raw.ReadDiscInformation() # not always present; fall through to raw TOC
|
|
25
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
|
|
26
|
+
$fmt.Recorder = $rec
|
|
27
|
+
|
|
28
|
+
# MsftDiscFormat2RawCD.get_TocInformation() -> byte array of the raw TOC (MMC-3).
|
|
29
|
+
$bytes = $raw.ReadTocInformation()
|
|
30
|
+
# TOC header: [0..1]=data length, [2]=first track, [3]=last track.
|
|
31
|
+
$first = $bytes[2]; $last = $bytes[3]
|
|
32
|
+
$offsets = @()
|
|
33
|
+
$leadout = 0
|
|
34
|
+
for ($i = 4; $i + 7 -lt $bytes.Length; $i += 8) {
|
|
35
|
+
$trk = $bytes[$i + 2]
|
|
36
|
+
# LBA is big-endian in bytes [i+4..i+7]
|
|
37
|
+
$lba = ($bytes[$i+4] -shl 24) -bor ($bytes[$i+5] -shl 16) -bor ($bytes[$i+6] -shl 8) -bor $bytes[$i+7]
|
|
38
|
+
if ($trk -eq 0xAA) { $leadout = $lba + 150 }
|
|
39
|
+
elseif ($trk -ge $first -and $trk -le $last) { $offsets += ($lba + 150) }
|
|
40
|
+
}
|
|
41
|
+
if ($offsets.Count -eq 0) { Write-Error "no audio tracks in TOC"; exit 2 }
|
|
42
|
+
|
|
43
|
+
Write-Output (ConvertTo-JsonCompat @{ track_count = $offsets.Count; leadout = $leadout; tracks = $offsets })
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Burn an audio CD (Red Book) from a folder of 16-bit / 44.1 kHz stereo WAV
|
|
2
|
+
# files via IMAPI2 Track-At-Once. Streams "PROGRESS:<pct>".
|
|
3
|
+
# Usage: burn-audio.ps1 <drive e.g. D:> <wav folder> [speed]
|
|
4
|
+
param(
|
|
5
|
+
[Parameter(Mandatory = $true)] [string] $Drive,
|
|
6
|
+
[Parameter(Mandatory = $true)] [string] $WavDir,
|
|
7
|
+
[string] $Speed = ""
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
$ErrorActionPreference = "Stop"
|
|
11
|
+
$wavs = @(Get-ChildItem -LiteralPath $WavDir -Filter *.wav | Sort-Object Name)
|
|
12
|
+
if ($wavs.Count -eq 0) { Write-Error "no WAV files in $WavDir"; exit 2 }
|
|
13
|
+
|
|
14
|
+
function Get-Recorder([string]$letter) {
|
|
15
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
16
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
17
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
18
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
19
|
+
foreach ($p in $rec.VolumePathNames) {
|
|
20
|
+
if ($p -and $p.TrimEnd('\') -ieq $letter) { return $rec }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw "No optical recorder for $letter"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
$rec = Get-Recorder $Drive
|
|
27
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2TrackAtOnce"
|
|
28
|
+
if (-not $fmt.IsRecorderSupported($rec)) { Write-Error "recorder not supported"; exit 3 }
|
|
29
|
+
$fmt.Recorder = $rec
|
|
30
|
+
$fmt.ClientName = "DiscStation"
|
|
31
|
+
try { $fmt.NumberOfExistingTracks } catch {}
|
|
32
|
+
if ($Speed -and $Speed -match '^\d+') {
|
|
33
|
+
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
$prepared = @()
|
|
37
|
+
foreach ($w in $wavs) {
|
|
38
|
+
# IMAPI2 wants raw 44100/16/2 PCM. Strip the 44-byte WAV header.
|
|
39
|
+
$bytes = [System.IO.File]::ReadAllBytes($w.FullName)
|
|
40
|
+
$offset = 44
|
|
41
|
+
$idx = -1
|
|
42
|
+
for ($i = 12; $i -lt [Math]::Min($bytes.Length - 8, 4096); $i++) {
|
|
43
|
+
if ($bytes[$i] -eq 0x64 -and $bytes[$i+1] -eq 0x61 -and $bytes[$i+2] -eq 0x74 -and $bytes[$i+3] -eq 0x61) {
|
|
44
|
+
$offset = $i + 8; break
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
$rawLen = $bytes.Length - $offset
|
|
48
|
+
# Red Book requires each track's byte length to be an exact multiple of
|
|
49
|
+
# the 2352-byte CD-DA sector - real-world track lengths essentially
|
|
50
|
+
# never land on that boundary naturally. AddAudioTrack rejects anything
|
|
51
|
+
# else outright ("The provided audio stream is not valid."). Pad with
|
|
52
|
+
# silence up to the next sector boundary (and up to the 4-second/
|
|
53
|
+
# 300-sector minimum a track must have) rather than trim real audio.
|
|
54
|
+
$sectorSize = 2352
|
|
55
|
+
$minLen = 300 * $sectorSize
|
|
56
|
+
$paddedLen = [Math]::Ceiling([Math]::Max($rawLen, $minLen) / $sectorSize) * $sectorSize
|
|
57
|
+
$raw = New-Object byte[] $paddedLen
|
|
58
|
+
[Array]::Copy($bytes, $offset, $raw, 0, $rawLen)
|
|
59
|
+
$prepared += ,@{ name = $w.Name; data = $raw }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
$total = $prepared.Count
|
|
63
|
+
$done = 0
|
|
64
|
+
try {
|
|
65
|
+
# AddAudioTrack throws E_IMAPI_DF2TAO_MEDIA_IS_NOT_PREPARED ("only valid
|
|
66
|
+
# when media has been prepared") without this - PrepareMedia locks the
|
|
67
|
+
# drive for the write session, ReleaseMedia below hands it back.
|
|
68
|
+
$fmt.PrepareMedia()
|
|
69
|
+
foreach ($t in $prepared) {
|
|
70
|
+
$stream = New-Object -ComObject "ADODB.Stream"
|
|
71
|
+
$stream.Type = 1; $stream.Open()
|
|
72
|
+
$stream.Write($t.data)
|
|
73
|
+
$stream.Position = 0
|
|
74
|
+
$fmt.AddAudioTrack($stream)
|
|
75
|
+
$stream.Close()
|
|
76
|
+
$done++
|
|
77
|
+
Write-Output ("PROGRESS:" + [int]([math]::Min(99, $done * 100.0 / $total)))
|
|
78
|
+
}
|
|
79
|
+
$fmt.ReleaseMedia()
|
|
80
|
+
$fmt.Recorder.EjectMedia()
|
|
81
|
+
Write-Output "PROGRESS:100"
|
|
82
|
+
exit 0
|
|
83
|
+
} catch {
|
|
84
|
+
try { $fmt.ReleaseMedia() } catch {}
|
|
85
|
+
Write-Error ("audio burn failed: " + $_.Exception.Message)
|
|
86
|
+
exit 1
|
|
87
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Build a data-disc filesystem image from a folder (or single file) and burn it
|
|
2
|
+
# via IMAPI2 - no external mkisofs needed. Streams "PROGRESS:<pct>".
|
|
3
|
+
# Usage: burn-data.ps1 <drive e.g. D:> <source folder-or-file> <label> [speed]
|
|
4
|
+
param(
|
|
5
|
+
[Parameter(Mandatory = $true)] [string] $Drive,
|
|
6
|
+
[Parameter(Mandatory = $true)] [string] $Source,
|
|
7
|
+
[Parameter(Mandatory = $true)] [string] $Label,
|
|
8
|
+
[string] $Speed = ""
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
$ErrorActionPreference = "Stop"
|
|
12
|
+
if (-not (Test-Path $Source)) { Write-Error "source not found: $Source"; exit 2 }
|
|
13
|
+
|
|
14
|
+
function Get-Recorder([string]$letter) {
|
|
15
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
16
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
17
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
18
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
19
|
+
foreach ($p in $rec.VolumePathNames) {
|
|
20
|
+
if ($p -and $p.TrimEnd('\') -ieq $letter) { return $rec }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw "No optical recorder for $letter"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
$rec = Get-Recorder $Drive
|
|
27
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
|
|
28
|
+
if (-not $fmt.IsRecorderSupported($rec)) { Write-Error "recorder not supported"; exit 3 }
|
|
29
|
+
$fmt.Recorder = $rec
|
|
30
|
+
$fmt.ClientName = "DiscStation"
|
|
31
|
+
# Without this the disc session never finalizes - the drive reports the
|
|
32
|
+
# disc as still blank afterward even though the data is physically there.
|
|
33
|
+
try { $fmt.ForceMediaToBeClosed = $true } catch {}
|
|
34
|
+
if ($Speed -and $Speed -match '^\d+') {
|
|
35
|
+
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Filesystem image: ISO9660 + Joliet + UDF, sized to the loaded media.
|
|
39
|
+
$fsi = New-Object -ComObject "IMAPI2FS.MsftFileSystemImage"
|
|
40
|
+
try { $fsi.ChooseImageDefaultsForMediaType($fmt.CurrentPhysicalMediaType) } catch {}
|
|
41
|
+
$fsi.FileSystemsToCreate = 7 # ISO9660 | Joliet | UDF
|
|
42
|
+
$fsi.VolumeName = ($Label -replace '[^A-Za-z0-9_\- ]', '').Substring(0, [Math]::Min(32, ($Label -replace '[^A-Za-z0-9_\- ]', '').Length))
|
|
43
|
+
$fsi.FreeMediaBlocks = -1 # -1 = use the whole disc
|
|
44
|
+
|
|
45
|
+
$item = Get-Item -LiteralPath $Source
|
|
46
|
+
if ($item.PSIsContainer) {
|
|
47
|
+
# AddTree's 2nd arg is IncludeBaseDirectory: $false flattens a folder
|
|
48
|
+
# child into just its contents at the disc root (dropping the folder
|
|
49
|
+
# name entirely) - wrong for VIDEO_TS/AUDIO_TS or any subfolder, which
|
|
50
|
+
# need to keep their own name. $true preserves it as a real subfolder.
|
|
51
|
+
foreach ($child in Get-ChildItem -LiteralPath $Source) { $fsi.Root.AddTree($child.FullName, $true) }
|
|
52
|
+
} else {
|
|
53
|
+
$fsi.Root.AddTree($item.FullName, $false)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
$result = $fsi.CreateResultImage()
|
|
57
|
+
$stream = $result.ImageStream
|
|
58
|
+
|
|
59
|
+
# Not fatal if registration fails (seen on some setups: "Cannot register
|
|
60
|
+
# for the specified event... does not exist") - the burn itself doesn't
|
|
61
|
+
# need it, just no live PROGRESS lines.
|
|
62
|
+
try {
|
|
63
|
+
Register-ObjectEvent -InputObject $fmt -EventName "Update" -SourceIdentifier "burn" -Action {
|
|
64
|
+
$s = $EventArgs
|
|
65
|
+
try {
|
|
66
|
+
$done = [double]$s.LastWrittenLba
|
|
67
|
+
$tot = [double]$s.SectorCount
|
|
68
|
+
if ($tot -gt 0) { Write-Output ("PROGRESS:" + [int]([math]::Min(99, $done * 100.0 / $tot))) }
|
|
69
|
+
} catch {}
|
|
70
|
+
} | Out-Null
|
|
71
|
+
} catch {}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
$fmt.Write($stream)
|
|
75
|
+
Write-Output "PROGRESS:100"
|
|
76
|
+
exit 0
|
|
77
|
+
} catch {
|
|
78
|
+
Write-Error ("burn failed: " + $_.Exception.Message)
|
|
79
|
+
exit 1
|
|
80
|
+
} finally {
|
|
81
|
+
Unregister-Event -SourceIdentifier "burn" -ErrorAction SilentlyContinue
|
|
82
|
+
try { $rec.EjectMedia() } catch {}
|
|
83
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Burn a pre-built ISO to the optical drive via IMAPI2. Streams "PROGRESS:<pct>"
|
|
2
|
+
# lines to stdout. Usage: burn-image.ps1 <drive e.g. D:> <iso path> [speed]
|
|
3
|
+
param(
|
|
4
|
+
[Parameter(Mandatory = $true)] [string] $Drive,
|
|
5
|
+
[Parameter(Mandatory = $true)] [string] $Iso,
|
|
6
|
+
[string] $Speed = ""
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
$ErrorActionPreference = "Stop"
|
|
10
|
+
if (-not (Test-Path $Iso)) { Write-Error "ISO not found: $Iso"; exit 2 }
|
|
11
|
+
|
|
12
|
+
function Get-Recorder([string]$letter) {
|
|
13
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
14
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
15
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
16
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
17
|
+
foreach ($p in $rec.VolumePathNames) {
|
|
18
|
+
if ($p -and $p.TrimEnd('\') -ieq $letter) { return $rec }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw "No optical recorder for $letter"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
$rec = Get-Recorder $Drive
|
|
25
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
|
|
26
|
+
if (-not $fmt.IsRecorderSupported($rec)) { Write-Error "recorder not supported"; exit 3 }
|
|
27
|
+
$fmt.Recorder = $rec
|
|
28
|
+
$fmt.ClientName = "DiscStation"
|
|
29
|
+
try { $fmt.ForceMediaToBeClosed = $true } catch {}
|
|
30
|
+
if ($Speed -and $Speed -match '^\d+') {
|
|
31
|
+
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Progress: IMAPI2 raises an Update event with sector counts. Not fatal if
|
|
35
|
+
# registration fails (seen on some setups: "Cannot register for the
|
|
36
|
+
# specified event... does not exist") - the burn itself doesn't need it,
|
|
37
|
+
# just no live PROGRESS lines.
|
|
38
|
+
try {
|
|
39
|
+
Register-ObjectEvent -InputObject $fmt -EventName "Update" -SourceIdentifier "burn" -Action {
|
|
40
|
+
$s = $EventArgs
|
|
41
|
+
try {
|
|
42
|
+
$done = [double]$s.LastWrittenLba
|
|
43
|
+
$tot = [double]$s.SectorCount
|
|
44
|
+
if ($tot -gt 0) {
|
|
45
|
+
$pct = [int]([math]::Min(99, $done * 100.0 / $tot))
|
|
46
|
+
Write-Output "PROGRESS:$pct"
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
} | Out-Null
|
|
50
|
+
} catch {}
|
|
51
|
+
|
|
52
|
+
$stream = New-Object -ComObject "ADODB.Stream"
|
|
53
|
+
$stream.Type = 1 # binary
|
|
54
|
+
$stream.Open()
|
|
55
|
+
$stream.LoadFromFile($Iso)
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
$fmt.Write($stream)
|
|
59
|
+
Write-Output "PROGRESS:100"
|
|
60
|
+
exit 0
|
|
61
|
+
} catch {
|
|
62
|
+
Write-Error ("burn failed: " + $_.Exception.Message)
|
|
63
|
+
exit 1
|
|
64
|
+
} finally {
|
|
65
|
+
$stream.Close()
|
|
66
|
+
Unregister-Event -SourceIdentifier "burn" -ErrorAction SilentlyContinue
|
|
67
|
+
try { $rec.EjectMedia() } catch {}
|
|
68
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Optical drive + media state for DiscStation. Emits one JSON line.
|
|
2
|
+
# Works on PowerShell 2.0 (Win7) and later. Optional arg: a drive letter ("D:")
|
|
3
|
+
# to force; otherwise the first optical drive is used.
|
|
4
|
+
param([string]$Drive = "")
|
|
5
|
+
|
|
6
|
+
$ErrorActionPreference = "Stop"
|
|
7
|
+
. (Join-Path $PSScriptRoot "_json.ps1")
|
|
8
|
+
|
|
9
|
+
$out = @{ drive = ""; media_loaded = $false; blank = $false; label = "";
|
|
10
|
+
fs = ""; media_type = ""; rewritable = $false; capacity_bytes = 0 }
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
$cd = @(Get-WmiObject Win32_CDROMDrive)
|
|
14
|
+
if ($Drive) { $cd = @($cd | Where-Object { $_.Drive -eq $Drive }) }
|
|
15
|
+
if ($cd.Count -eq 0) { Write-Output (ConvertTo-JsonCompat $out); exit 0 }
|
|
16
|
+
$d = $cd[0]
|
|
17
|
+
$out.drive = $d.Drive
|
|
18
|
+
# NOTE: [bool]"False" is $true in PowerShell (any non-empty string casts
|
|
19
|
+
# truthy) -- compare explicitly instead of casting.
|
|
20
|
+
$out.media_loaded = ($d.MediaLoaded -eq $true) -or ("$($d.MediaLoaded)" -eq "True")
|
|
21
|
+
} catch { Write-Output (ConvertTo-JsonCompat $out); exit 0 }
|
|
22
|
+
|
|
23
|
+
if (-not $out.media_loaded) { Write-Output (ConvertTo-JsonCompat $out); exit 0 }
|
|
24
|
+
|
|
25
|
+
# Volume label + filesystem (WMI logical disk).
|
|
26
|
+
$isAudioCd = $false
|
|
27
|
+
try {
|
|
28
|
+
$ld = Get-WmiObject Win32_LogicalDisk -Filter ("DeviceID='" + $out.drive + "'")
|
|
29
|
+
if ($ld) {
|
|
30
|
+
if ($ld.VolumeName) { $out.label = $ld.VolumeName }
|
|
31
|
+
# Windows presents a synthetic CDFS view (fixed volume name "Audio
|
|
32
|
+
# CD") for audio discs, purely so Explorer can browse track01.cda
|
|
33
|
+
# files - it's not a real filesystem. Treating it as one made every
|
|
34
|
+
# audio CD misclassify as a data disc.
|
|
35
|
+
if ($ld.VolumeName -eq "Audio CD" -and "$($ld.FileSystem)" -ieq "CDFS") {
|
|
36
|
+
$isAudioCd = $true
|
|
37
|
+
} elseif ($ld.FileSystem) {
|
|
38
|
+
$fs = $ld.FileSystem.ToLower()
|
|
39
|
+
if ($fs -match "udf") { $out.fs = "udf" }
|
|
40
|
+
elseif ($fs -match "cdfs|iso9660") { $out.fs = "iso9660" }
|
|
41
|
+
else { $out.fs = $fs }
|
|
42
|
+
}
|
|
43
|
+
if ($ld.Size) { $out.capacity_bytes = [int64]$ld.Size }
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
|
|
47
|
+
# IMAPI2: physical media type, blank flag, recordable capacity.
|
|
48
|
+
try {
|
|
49
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
50
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
51
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
52
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
53
|
+
$match = $false
|
|
54
|
+
foreach ($p in $rec.VolumePathNames) { if ($p -and $p.TrimEnd('\') -ieq $out.drive) { $match = $true } }
|
|
55
|
+
if (-not $match) { continue }
|
|
56
|
+
$fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
|
|
57
|
+
if (-not $fmt.IsRecorderSupported($rec)) { break }
|
|
58
|
+
$fmt.Recorder = $rec
|
|
59
|
+
$fmt.ClientName = "DiscStation"
|
|
60
|
+
try { $out.blank = ($fmt.MediaHeuristicallyBlank -eq $true) } catch {}
|
|
61
|
+
try { if ($fmt.MediaPhysicallyBlank) { $out.blank = $true } } catch {}
|
|
62
|
+
try { $out.capacity_bytes = [int64]$fmt.TotalSectorsOnMedia * 2048 } catch {}
|
|
63
|
+
$t = 0; try { $t = [int]$fmt.CurrentPhysicalMediaType } catch {}
|
|
64
|
+
# IMAPI_MEDIA_PHYSICAL_TYPE
|
|
65
|
+
$map = @{ 1="cd-rom"; 2="cd-r"; 3="cd-rw"; 4="dvd-rom"; 5="dvd-r"; 6="dvd-ram";
|
|
66
|
+
7="dvd+r"; 8="dvd+rw"; 9="dvd+r dl"; 10="dvd-r dl"; 12="dvd+rw dl";
|
|
67
|
+
16="bd-rom"; 17="bd-r"; 18="bd-re" }
|
|
68
|
+
if ($map.ContainsKey($t)) { $out.media_type = $map[$t] }
|
|
69
|
+
if ($out.media_type -match "rw|ram|-re") { $out.rewritable = $true }
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
} catch {}
|
|
73
|
+
|
|
74
|
+
# $isAudioCd (Windows' own synthetic "Audio CD" CDFS view, detected above) is
|
|
75
|
+
# authoritative - overrides whatever IMAPI2's physical-media-type guess said,
|
|
76
|
+
# since a finalized audio CD-R still reports as generic "cd-rom" there.
|
|
77
|
+
if ($isAudioCd) {
|
|
78
|
+
$out.media_type = "audio_cd"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Write-Output (ConvertTo-JsonCompat $out)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Eject or close the optical tray via IMAPI2, with a Shell.Application fallback.
|
|
2
|
+
# Usage: eject.ps1 <drive letter e.g. D:> [close]
|
|
3
|
+
param([string]$Drive = "", [switch]$Close)
|
|
4
|
+
|
|
5
|
+
$ErrorActionPreference = "Stop"
|
|
6
|
+
$ok = $false
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
$master = New-Object -ComObject "IMAPI2.MsftDiscMaster2"
|
|
10
|
+
for ($i = 0; $i -lt $master.Count; $i++) {
|
|
11
|
+
$rec = New-Object -ComObject "IMAPI2.MsftDiscRecorder2"
|
|
12
|
+
$rec.InitializeDiscRecorder($master.Item($i))
|
|
13
|
+
$match = -not $Drive
|
|
14
|
+
foreach ($p in $rec.VolumePathNames) { if ($p -and $Drive -and $p.TrimEnd('\') -ieq $Drive) { $match = $true } }
|
|
15
|
+
if (-not $match) { continue }
|
|
16
|
+
if ($Close) { $rec.CloseTray() } else { $rec.EjectMedia() }
|
|
17
|
+
$ok = $true
|
|
18
|
+
break
|
|
19
|
+
}
|
|
20
|
+
} catch {}
|
|
21
|
+
|
|
22
|
+
if (-not $ok -and -not $Close -and $Drive) {
|
|
23
|
+
try {
|
|
24
|
+
$sh = New-Object -ComObject "Shell.Application"
|
|
25
|
+
$sh.Namespace(17).ParseName($Drive).InvokeVerb("Eject")
|
|
26
|
+
$ok = $true
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if ($ok) { exit 0 } else { Write-Error "eject failed"; exit 1 }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Play a Red Book audio CD via Windows Media Player's COM control - mpv on
|
|
2
|
+
# Windows has no libcdio, so cdda:// is "disabled at compile-time" there.
|
|
3
|
+
# WMPlayer.OCX only actually plays when hosted in a real window with a
|
|
4
|
+
# message pump; a bare `New-Object -ComObject` never leaves playState
|
|
5
|
+
# "Ready". Runs as a tiny persistent process, polled by the caller:
|
|
6
|
+
#
|
|
7
|
+
# Commands: caller writes one command to $CmdFile (PAUSE | STOP | NEXT |
|
|
8
|
+
# PREV | VOL:<0-100>); this script deletes it once consumed. A file-based
|
|
9
|
+
# channel instead of stdin - a background thread reading Console stdin
|
|
10
|
+
# here crashed the whole process outright (a piped stdin under a
|
|
11
|
+
# powershell.exe launched via -File from a console-less pythonw.exe
|
|
12
|
+
# parent apparently isn't safe to read from a second thread).
|
|
13
|
+
# Status (stdout): TRACK:<0-based index> | DONE | ERROR:<message>
|
|
14
|
+
#
|
|
15
|
+
# Usage: play-audio-cd.ps1 <drive e.g. D:> <command file path>
|
|
16
|
+
param(
|
|
17
|
+
[Parameter(Mandatory = $true)] [string] $Drive,
|
|
18
|
+
[Parameter(Mandatory = $true)] [string] $CmdFile
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
$ErrorActionPreference = "Stop"
|
|
22
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
23
|
+
|
|
24
|
+
# Write-Output through a redirected/piped stdout (SSH, or Python's own
|
|
25
|
+
# subprocess.PIPE) buffers until the process exits instead of flushing
|
|
26
|
+
# per line - confirmed live (only the final "DONE" ever arrived; every
|
|
27
|
+
# "TRACK:" sent during actual playback was stuck in the buffer). Writing
|
|
28
|
+
# straight to the console stream and flushing after each line sidesteps
|
|
29
|
+
# PowerShell's own output-pipeline buffering.
|
|
30
|
+
function Send-Status([string]$msg) {
|
|
31
|
+
[Console]::Out.WriteLine($msg)
|
|
32
|
+
[Console]::Out.Flush()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
$form = New-Object System.Windows.Forms.Form
|
|
37
|
+
$form.Show()
|
|
38
|
+
[System.Windows.Forms.Application]::DoEvents()
|
|
39
|
+
|
|
40
|
+
$wmp = New-Object -ComObject WMPlayer.OCX.7
|
|
41
|
+
$drives = $wmp.cdromCollection
|
|
42
|
+
$target = $null
|
|
43
|
+
for ($i = 0; $i -lt $drives.count; $i++) {
|
|
44
|
+
if ($drives.Item($i).driveSpecifier.TrimEnd('\') -ieq $Drive.TrimEnd('\')) { $target = $drives.Item($i); break }
|
|
45
|
+
}
|
|
46
|
+
if (-not $target) { Send-Status "ERROR:No CD drive $Drive"; exit 2 }
|
|
47
|
+
|
|
48
|
+
$wmp.currentPlaylist = $target.playlist
|
|
49
|
+
$wmp.controls.play()
|
|
50
|
+
} catch {
|
|
51
|
+
Send-Status ("ERROR:" + $_.Exception.Message)
|
|
52
|
+
exit 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
$lastTrack = -1
|
|
56
|
+
while ($true) {
|
|
57
|
+
Start-Sleep -Milliseconds 200
|
|
58
|
+
[System.Windows.Forms.Application]::DoEvents()
|
|
59
|
+
|
|
60
|
+
if ($wmp.playState -eq 8) { Send-Status "DONE"; break } # wmppsMediaEnded
|
|
61
|
+
|
|
62
|
+
# currentItem.playlistIndex comes back empty on this drive/build -
|
|
63
|
+
# confirmed live - but .name reliably reads "Track N" for a CD
|
|
64
|
+
# playlist item, so parse the number out of that instead.
|
|
65
|
+
$track = -1
|
|
66
|
+
try {
|
|
67
|
+
$item = $wmp.controls.currentItem
|
|
68
|
+
if ($item -and $item.name -match 'Track\s+(\d+)') { $track = [int]$Matches[1] - 1 }
|
|
69
|
+
} catch {}
|
|
70
|
+
if ($track -ge 0 -and $track -ne $lastTrack) {
|
|
71
|
+
$lastTrack = $track
|
|
72
|
+
Send-Status "TRACK:$track"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Test-Path -LiteralPath $CmdFile) {
|
|
76
|
+
$line = (Get-Content -LiteralPath $CmdFile -Raw -ErrorAction SilentlyContinue)
|
|
77
|
+
Remove-Item -LiteralPath $CmdFile -ErrorAction SilentlyContinue
|
|
78
|
+
if ($line) {
|
|
79
|
+
$line = $line.Trim()
|
|
80
|
+
switch -Regex ($line) {
|
|
81
|
+
'^PAUSE$' {
|
|
82
|
+
if ($wmp.playState -eq 3) { $wmp.controls.pause() } else { $wmp.controls.play() }
|
|
83
|
+
}
|
|
84
|
+
'^STOP$' { $wmp.controls.stop(); Send-Status "DONE"; break }
|
|
85
|
+
'^NEXT$' { $wmp.controls.next() }
|
|
86
|
+
'^PREV$' { $wmp.controls.previous() }
|
|
87
|
+
'^VOL:(\d+)$' { $wmp.settings.volume = [Math]::Min(100, [Math]::Max(0, [int]$Matches[1])) }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try { $wmp.controls.stop(); $wmp.close() } catch {}
|
|
94
|
+
try { $form.Close() } catch {}
|