Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,12 +242,14 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via

- `LocalWallpaperDataSourceImpl: WallpaperDataSource<LocalWallpaper>` — relative/absolute path resolution via Files library
- `RemoteWallpaperDataSourceImpl: WallpaperDataSource<RemoteWallpaper>` — HTTP(S) download with SHA256-keyed cache
- `YouTubeWallpaperDataSourceImpl: WallpaperDataSource<YouTubeWallpaper>` — yt-dlp/uvx download with H.264/AVC codec, SHA256-keyed cache
- `YouTubeWallpaperDataSourceImpl: WallpaperDataSource<YouTubeWallpaper>` — yt-dlp/uvx download, highest-quality video-only stream with HEVC transcode fallback (see Key Design Decisions), SHA256-keyed cache

**WallpaperRepository URL classification**: Repository classifies wallpaper config string and dispatches to the appropriate DataSource. Priority: local path (no scheme) → YouTube URL (host contains youtube.com/youtu.be) → remote HTTP(S) URL. All paths converge to a local file path string.

**Wallpaper cache**: `~/.cache/lyra/wallpapers/SHA256(url).{ext}`. Cache is permanent (wallpapers are reused). `WallpaperCache` helper shared by Remote and YouTube DataSources.

**YouTube highest-quality download + codec compatibility (#292)**: YouTube only serves H.264/AVC up to 1080p; 1440p/4K exist solely as VP9 or AV1. The old `player_client=android` selector is now crippled by YouTube SABR streaming — it skips every video-only format and leaves only the combined 360p (the "sometimes terrible quality" bug). The fix uses `player_client=default` (the web client), which publishes the full https DASH ladder at every resolution with no PO Token required. But raising the ceiling exposes a playback wall: **AVFoundation cannot decode AV1 on pre-M3 Apple Silicon or Intel Macs (`VTIsHardwareDecodeSupported(AV1)=false`), and never decodes VP9/WebM at all** — empirically confirmed on M1 Max. So `YouTubeWallpaperDataSourceImpl` gates the codec-agnostic `bestvideo[height<=maxHeight]` selector on a *transcode capability* check (`ffmpeg` **and** `ffprobe` both present): with the toolchain it grabs the best 4K stream and, after download, `ffprobe` detects the codec — AVC/HEVC are stream-copied (`-c copy`, cheap), while AV1/VP9 are hardware-transcoded to HEVC (`-c:v hevc_videotoolbox -tag:v hvc1`, ~4x realtime, one-time per wallpaper before the SHA256 cache fills). Without the toolchain it falls back to the AVC-only selector (natively playable, 1080p ceiling). `processRunner` returns `(status, stdout, stderr)` because codec detection needs `ffprobe`'s stdout.

**Wallpaper async resolution**: `WallpaperPresenter.start()` consumes `WallpaperInteractor.resolvedWallpapers()` — an `AsyncStream<ResolvedWallpaperItem>` — in a background Task, starting playback the moment the first item arrives. `WallpaperPresenter` also manages AVPlayer lifecycle (create, seek, loop, pause/play) and owns sleep/wake monitoring via `observeSleepWake()`.

**Multi-wallpaper playback**: `WallpaperStyle` is a list of `WallpaperItem` with a `WallpaperPlaybackMode` (`.cycle` or `.shuffle`). Config accepts a bare string, a legacy `[wallpaper]` table, or an array-of-tables `[[wallpaper.items]]` with optional `mode`; each item can specify optional trim (`start`/`end`) and per-item `scale`. `WallpaperInteractorImpl` resolves every item in parallel via a `TaskGroup`. In cycle mode it buffers completions and yields in configured order (skipping items that fail), so playback order is deterministic even when downloads finish out of order. In shuffle mode it yields items as they complete, so playback starts with whichever item resolves first. `WallpaperPresenter` advances to the next item on `AVPlayerItemDidPlayToEndTime` (or when the end-trim boundary is reached) when `items.count > 1`; `nextIndex(from:)` dispatches on mode — cycle uses `(current + 1) % count`, shuffle picks via `RandomSource.next(below:)` from indices excluding the current item.
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,17 @@ Remote and YouTube videos are downloaded once and cached in `~/.cache/lyra/wallp

| Tool | Install | Notes |
|---|---|---|
| `yt-dlp` | `brew install yt-dlp` | Preferred. Downloads video-only H.264 at up to 4K |
| `yt-dlp` | `brew install yt-dlp` | Preferred. Downloads the highest-quality video-only stream, up to 4K |
| `uvx` | `brew install uv` | Zero-install alternative — runs `uvx yt-dlp` without global install |
| `ffmpeg` | `brew install ffmpeg` | Required for auto-loop. Remuxes DASH container to standard MP4 |

If neither `yt-dlp` nor `uvx` is found, lyra will show an error. If `ffmpeg` is not found, the video plays but may not loop automatically.
| `ffmpeg` | `brew install ffmpeg` | Remuxes DASH to MP4 and adds `+faststart` for seamless looping (1080p H.264 ceiling) |
| `ffprobe` | included with `brew install ffmpeg` | Unlocks 4K: detects codec and transcodes AV1/VP9 → HEVC for pre-M3 Apple Silicon and Intel |

If neither `yt-dlp` nor `uvx` is found, lyra will show an error. `ffmpeg` alone
enables DASH-to-MP4 remuxing for seamless looping at the H.264 1080p ceiling.
Pair it with `ffprobe` (included in `brew install ffmpeg`) to unlock 4K: lyra
then downloads the best VP9/AV1 stream and hardware-transcodes non-natively-playable
codecs to HEVC so every Mac — including pre-M3 Apple Silicon and Intel — can play
it. Without `ffmpeg`, lyra downloads a direct H.264 stream that may not loop automatically.

**Trim playback range** (optional):

Expand Down
2 changes: 1 addition & 1 deletion Sources/VersionHandler/Resources/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.15.1
2.16.0
190 changes: 153 additions & 37 deletions Sources/WallpaperDataSource/YouTubeWallpaperDataSourceImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Foundation
public struct YouTubeWallpaperDataSourceImpl: Sendable {
@Dependency(\.processGateway) private var gateway
let tempPathFor: @Sendable (URL, String?) throws -> String
let processRunner: @Sendable (String, [String]) async throws -> (status: Int32, stderr: String)
let processRunner: @Sendable (String, [String]) async throws -> (status: Int32, stdout: String, stderr: String)
let fileExistsAtPath: @Sendable (String) -> Bool
let removeItemAtPath: @Sendable (String) -> Void
let replaceItemAtPath: @Sendable (String, String) -> Void
Expand Down Expand Up @@ -35,7 +35,7 @@ public struct YouTubeWallpaperDataSourceImpl: Sendable {

init(
tempPathFor: @escaping @Sendable (URL, String?) throws -> String,
processRunner: @escaping @Sendable (String, [String]) async throws -> (status: Int32, stderr: String),
processRunner: @escaping @Sendable (String, [String]) async throws -> (status: Int32, stdout: String, stderr: String),
fileExistsAtPath: @escaping @Sendable (String) -> Bool,
removeItemAtPath: @escaping @Sendable (String) -> Void,
replaceItemAtPath: @escaping @Sendable (String, String) -> Void
Expand All @@ -49,17 +49,24 @@ public struct YouTubeWallpaperDataSourceImpl: Sendable {
}

extension YouTubeWallpaperDataSourceImpl: WallpaperDataSource {
/// Downloads and remuxes to a temp file in the cache folder. Returns the temp file path.
/// Downloads and normalizes to a temp file in the cache folder. Returns the temp file path.
/// Cache deduplication is handled by WallpaperRepository.
public func resolve(_ location: YouTubeWallpaper) async throws -> String {
let tempPath = try tempPathFor(location.url, location.format)

// Highest-quality codecs (VP9 / AV1 4K) are not natively playable by AVFoundation on
// pre-M3 Apple Silicon and Intel Macs, so we only request them when we can transcode the
// result to HEVC. Without a transcode toolchain we stay on the natively-playable AVC ceiling.
let ffmpeg = findExecutable("ffmpeg")
let ffprobe = findExecutable("ffprobe")
let canTranscode = ffmpeg != nil && ffprobe != nil

let tool = try detectTool()
let args = buildArgs(
tool: tool, url: location.url, maxHeight: location.maxHeight,
format: location.format, destPath: tempPath)
format: location.format, destPath: tempPath, allowAnyCodec: canTranscode)

let (status, stderr) = try await processRunner(tool.executablePath, args)
let (status, _, stderr) = try await processRunner(tool.executablePath, args)

guard status == 0 else {
throw YouTubeDownloadError.downloadFailed(status: status, stderr: stderr)
Expand All @@ -69,7 +76,7 @@ extension YouTubeWallpaperDataSourceImpl: WallpaperDataSource {
throw YouTubeDownloadError.outputNotFound
}

try await remuxToStandardMP4(at: tempPath)
try await normalizeForPlayback(at: tempPath, ffmpeg: ffmpeg, ffprobe: ffprobe)

return tempPath
}
Expand Down Expand Up @@ -108,81 +115,188 @@ extension YouTubeWallpaperDataSourceImpl {
// MARK: - Command Building

extension YouTubeWallpaperDataSourceImpl {
func buildArgs(tool: Tool, url: URL, maxHeight: Int, format: String, destPath: String) -> [String] {
let ytdlpArgs = [
// Use the Android player client — the default web client triggers YouTube SABR
// streaming (HTTP 403). Android avoids SABR, but video-only formats require a
// GVS PO Token that we don't have, so they get skipped. Adding
// best[ext=format][height<=maxHeight] as fallback picks the combined A/V format
// (e.g. format 18) when video-only streams are unavailable. --no-audio is a
// no-op for combined formats and still suppresses separate audio downloads.
"--extractor-args", "youtube:player_client=android",
"-f", "bestvideo[ext=\(format)][height<=\(maxHeight)][vcodec^=avc]/best[ext=\(format)][height<=\(maxHeight)]",
"--no-audio",
"-o", destPath,
url.absoluteString,
]
func buildArgs(
tool: Tool, url: URL, maxHeight: Int, format: String, destPath: String, allowAnyCodec: Bool
) -> [String] {
let ytdlpArgs =
[
// The Android player client is now crippled by YouTube SABR streaming, which skips
// every video-only format and leaves only the combined 360p (format 18) — the
// "sometimes terrible quality" bug. The default web client publishes the full
// https DASH ladder (all resolutions, all codecs) with no PO Token required, so it
// is the client that can actually reach 4K. --no-audio keeps this to a video-only
// download (audio is never needed for a wallpaper).
"--extractor-args", "youtube:player_client=default",
"-f", formatSelector(maxHeight: maxHeight, format: format, allowAnyCodec: allowAnyCodec),
"--no-audio",
"--no-progress",
"-o", destPath,
url.absoluteString,
]
switch tool {
case .ytdlp: return ytdlpArgs
case .uvx: return ["yt-dlp"] + ytdlpArgs
}
}

/// When `allowAnyCodec` is true, take the highest-resolution video-only stream regardless of
/// codec (VP9 / AV1 reach 4K; the result is transcoded to HEVC downstream). Otherwise restrict
/// to AVC, which AVFoundation plays natively but YouTube caps at 1080p.
func formatSelector(maxHeight: Int, format: String, allowAnyCodec: Bool) -> String {
guard allowAnyCodec else {
return "bestvideo[ext=\(format)][height<=\(maxHeight)][vcodec^=avc]/best[ext=\(format)][height<=\(maxHeight)]"
}
return "bestvideo[height<=\(maxHeight)]/best[height<=\(maxHeight)]"
}
}

// MARK: - Remux
// MARK: - Playback Normalization

extension YouTubeWallpaperDataSourceImpl {
/// Remux DASH container to standard MP4 for AVPlayer compatibility (loop support).
private func remuxToStandardMP4(at path: String) async throws {
guard let ffmpeg = findExecutable("ffmpeg") else { return }
let tmpPath = path + ".remux.mp4"
/// Rewrites the downloaded file into an AVFoundation-playable MP4: AVC/HEVC are stream-copied
/// (cheap), while AV1/VP9 are hardware-transcoded to HEVC so every Mac can play the wallpaper.
/// A no-op when ffmpeg is unavailable (the raw DASH file is used as-is).
private func normalizeForPlayback(at path: String, ffmpeg: String?, ffprobe: String?) async throws {
guard let ffmpeg else { return }

let needsTranscode = await self.needsTranscode(at: path, ffprobe: ffprobe)
let tmpPath = path + ".normalized.mp4"
removeItemAtPath(tmpPath)
let (status, stderr) = try await processRunner(
ffmpeg,
["-nostdin", "-y", "-i", path, "-c", "copy", "-movflags", "+faststart", tmpPath]
)

let arguments =
needsTranscode
? Self.transcodeArguments(input: path, output: tmpPath)
: Self.remuxArguments(input: path, output: tmpPath)
let (status, _, stderr) = try await processRunner(ffmpeg, arguments)

guard status == 0 else {
removeItemAtPath(tmpPath)
throw YouTubeDownloadError.remuxFailed(stderr: stderr)
throw needsTranscode
? YouTubeDownloadError.transcodeFailed(stderr: stderr)
: YouTubeDownloadError.remuxFailed(stderr: stderr)
}
replaceItemAtPath(path, tmpPath)
}

/// Whether the downloaded file must be hardware-transcoded to HEVC for universal playback.
/// Without `ffprobe` we only ever requested the natively-playable AVC stream, so a stream
/// copy is safe. With `ffprobe` available we transcode AV1/VP9 — and, crucially, also
/// transcode when codec detection itself *fails*: the `allowAnyCodec` download path may have
/// produced an AV1/VP9 file, and defaulting a probe failure to a stream copy would silently
/// leave it unplayable on pre-M3 Apple Silicon / Intel Macs, defeating the every-Mac guarantee.
func needsTranscode(at path: String, ffprobe: String?) async -> Bool {
guard ffprobe != nil else { return false }
let codec = await videoCodec(at: path, ffprobe: ffprobe)
return codec.map(Self.requiresTranscode) ?? true
}

/// The codec name of the first video stream, lowercased, or nil when ffprobe is unavailable or
/// detection fails (callers then default to a cheap stream copy).
func videoCodec(at path: String, ffprobe: String?) async -> String? {
guard let ffprobe else { return nil }
let arguments = [
"-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=codec_name", "-of", "default=nw=1:nokey=1", path,
]
guard let result = try? await processRunner(ffprobe, arguments), result.status == 0 else {
return nil
}
let codec = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return codec.isEmpty ? nil : codec
}

/// AVFoundation cannot decode AV1 (pre-M3) or VP9 (ever), so those must be transcoded.
static func requiresTranscode(_ codec: String) -> Bool {
["av1", "av01", "vp9", "vp09"].contains(codec)
}

static func remuxArguments(input: String, output: String) -> [String] {
["-nostdin", "-y", "-i", input, "-c", "copy", "-movflags", "+faststart", output]
}

static func transcodeArguments(input: String, output: String) -> [String] {
[
"-nostdin", "-y", "-i", input, "-an",
"-c:v", "hevc_videotoolbox", "-tag:v", "hvc1", "-movflags", "+faststart", output,
]
}
}

// MARK: - Async Process

extension YouTubeWallpaperDataSourceImpl {
static func executeProcess(executablePath: String, arguments: [String]) async throws -> (status: Int32, stderr: String) {
static func executeProcess(executablePath: String, arguments: [String]) async throws -> (status: Int32, stdout: String, stderr: String) {
try await withCheckedThrowingContinuation { continuation in
let process = Process()
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.standardInput = FileHandle.nullDevice
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe

Comment on lines +234 to 238

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

action

5ad7bac修正しました。stdout/stderr の両パイプを DispatchGroup 配下の別スレッドで並行ドレインするよう書き直し、OS パイプバッファ(約 64 KB)の枯渇によるデッドロックを解消しています。group.notify 内で process.waitUntilExit() を先に呼ぶことで、NSTask の SIGCHLD 処理が完了してから terminationStatus を読むようにしました。

process.terminationHandler = { proc in
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let stderrString = String(data: stderrData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
continuation.resume(returning: (proc.terminationStatus, stderrString))
}

do {
try process.run()
} catch {
continuation.resume(throwing: error)
return
}

// Drain both pipes concurrently on background threads so that neither fills
// its OS pipe buffer and deadlocks the child process. ffmpeg in particular
// streams extensive progress output to stderr throughout a transcode, which
// can easily exceed the ~64 KB pipe buffer before the process exits.
let buffer = PipeBuffer()
let group = DispatchGroup()

group.enter()
DispatchQueue.global().async {
buffer.stdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}

group.enter()
DispatchQueue.global().async {
buffer.stderr = stderrPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}

group.notify(queue: .global()) {
// Both pipes have drained (EOF received → child has exited), but NSTask's
// internal SIGCHLD processing may not have run yet. waitUntilExit() ensures
// terminationStatus is valid before we read it.
process.waitUntilExit()
continuation.resume(
returning: (process.terminationStatus, buffer.stdoutTrimmed, buffer.stderrTrimmed))
}
}
}
}

/// Accumulates stdout and stderr bytes from concurrent pipe-drain tasks.
/// Marked `@unchecked Sendable` because each stored property is written by exactly
/// one DispatchQueue task and read only after the DispatchGroup barrier — no lock needed.
private final class PipeBuffer: @unchecked Sendable {
var stdout = Data()
var stderr = Data()

var stdoutTrimmed: String { trimmed(stdout) }
var stderrTrimmed: String { trimmed(stderr) }

private func trimmed(_ data: Data) -> String {
String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}

// MARK: - Errors

public enum YouTubeDownloadError: Error, CustomStringConvertible {
case toolNotFound
case downloadFailed(status: Int32, stderr: String)
case outputNotFound
case remuxFailed(stderr: String)
case transcodeFailed(stderr: String)

public var description: String {
switch self {
Expand All @@ -194,6 +308,8 @@ public enum YouTubeDownloadError: Error, CustomStringConvertible {
"yt-dlp completed but output file not found"
case .remuxFailed(let stderr):
"ffmpeg remux failed" + (stderr.isEmpty ? "" : "\n\(stderr)")
case .transcodeFailed(let stderr):
"ffmpeg HEVC transcode failed" + (stderr.isEmpty ? "" : "\n\(stderr)")
}
}
}
Loading
Loading