diff --git a/noctalia-ytmusic/README.md b/noctalia-ytmusic/README.md new file mode 100644 index 00000000..48e3a520 --- /dev/null +++ b/noctalia-ytmusic/README.md @@ -0,0 +1,109 @@ +# Noctalia YT Music + +A YouTube Music client for Noctalia v5 with a bar miniplayer and a full shell +panel — browse your library, playlists, and recommendations, and control +playback with full transport controls, offline caching, and session restore. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `aabidk20/noctalia-ytmusic` | +| Entries | Bar widget: `widget`; panels: `mini`, `panel`; service: `service` | + +## Requirements + +Install these tools on your `PATH`: + +- `yt-dlp` — stream resolution, audio downloads, and view/like counts +- `mpv` — playback engine +- `mpv-mpris` — MPRIS control of mpv *(optional)* +- `jq` — JSON parsing in YouTube API requests +- `curl` — YouTube API and thumbnail requests +- `nc` — mpv socket IPC + +## Usage + +Add the bar widget to a bar and bind the widget actions (left click opens the +miniplayer, right click opens the full panel, middle click opens the plugin +settings). You can also toggle any entry with its IPC command: + +```sh +noctalia msg panel-toggle aabidk20/noctalia-ytmusic:mini +noctalia msg panel-toggle aabidk20/noctalia-ytmusic:panel +``` + +The full panel groups everything: a home feed with Quick Picks, recommended +mixes and radios, and your library; playlist and queue views; and a search with +Top, Songs, and Playlists tabs. The miniplayer shows the current track with +play/pause, previous/next, shuffle, repeat, a seek scrubber, volume and mute, +like/unlike, and a hover tooltip with codec and bitrate info. + +On first launch, sign in from the sidebar to extract your YouTube Music +cookies from an installed browser (Chrome, Chromium, Firefox, Edge, Brave, +Opera, Vivaldi, Whale, or Zen). + +### Playback + +Play any track, playlist, or search result; queue controls work across track, +playlist, and search indexes. Playback resumes after a restart via a saved +session. Audio is decoded via `yt-dlp`'s best-audio format; with a YouTube +Premium account you get the higher-quality 256 kbps Opus streams where +available. + +### Offline + +Download individual tracks or entire playlists for offline playback. Audio, +stream, thumbnail, and playlist caches are sized and clearable in Settings. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `debug_logging` | `bool` | `false` | Write verbose logs to the plugin log file to help troubleshoot issues. | + +## IPC + +The service exposes actions via `noctalia msg` using the plugin state channel: + +```sh +noctalia msg plugin aabidk20/noctalia-ytmusic: all [payload] +``` + +The bar widget also opens entries directly (`panel-toggle aabidk20/noctalia-ytmusic:mini` and `aabidk20/noctalia-ytmusic:panel`). + +## Notes + +- **Network** — talks only to YouTube Music (`music.youtube.com`) and its + thumbnail host (`i.ytimg.com`) via `curl`, `yt-dlp`, and the native HTTP + API. No third-party servers. +- **Files** — cookies, playlists, sessions, quick picks, stats, and all audio / + stream / thumbnail caches live under `$XDG_CACHE_HOME/noctalia-ytmusic/`; + transient scratch files and the mpv socket use `/tmp`. +- **Cookie extraction** — on sign-in the plugin reads your browser's YouTube + cookies locally via `yt-dlp --cookies-from-browser` and stores them only in + the cache directory above. Nothing is ever sent anywhere except directly to + YouTube for playback and preference requests. See [Privacy](#privacy) below. +- **Processes** — spawns the tools listed in Requirements plus a browser on the + sign-in page to authenticate. + +### Debug log + +With the `debug_logging` setting enabled, the plugin writes a `debug.log` to +the cache directory. It records actions and the IDs/names involved (track and +playlist titles, search queries, video and playlist IDs, download activity) to +help troubleshoot. Cookie values are never written to it. If you share the +`debug.log` when filing an issue, be aware it may contain the names of +playlists and tracks and other identifying activity; scrub anything private +before posting. + +## Privacy + +This plugin reads browser cookies locally on your machine to authenticate with +YouTube Music, and stores them only on disk in your user's cache directory. +Nothing is shared: no analytics, no telemetry, no external servers — cookies, +tokens, and stream URLs never leave your machine. + +## License + +MIT \ No newline at end of file diff --git a/noctalia-ytmusic/api.luau b/noctalia-ytmusic/api.luau new file mode 100644 index 00000000..9f4d511d --- /dev/null +++ b/noctalia-ytmusic/api.luau @@ -0,0 +1,26 @@ +local API = {} + +local const = require("./api/constants.luau") +local COOKIE_PATH = const.COOKIE_PATH + +local REQUIRED_MODULES = { + "./api/search.luau", + "./api/stats.luau", + "./api/thumbnails.luau", + "./api/likes.luau", + "./api/cache.luau", + "./api/browse.luau", + "./api/resolve.luau", +} + +for _, path in ipairs(REQUIRED_MODULES) do + for k, v in pairs(require(path)) do + API[k] = v + end +end + +function API.cookies_available(cb) + cb(noctalia.fileExists(COOKIE_PATH)) +end + +return API \ No newline at end of file diff --git a/noctalia-ytmusic/api/browse.luau b/noctalia-ytmusic/api/browse.luau new file mode 100644 index 00000000..a85c2f00 --- /dev/null +++ b/noctalia-ytmusic/api/browse.luau @@ -0,0 +1,281 @@ +--!strict + +-- browse/home/library endpoints + parse. + +local logger = require("../utils/log.luau") +local log = logger.module("ytapi", "browse") + +local helpers = require("../utils/helpers.luau") +local sanitize = helpers.sanitize +local clean_artist = helpers.clean_artist +local fmt_seconds = helpers.fmt_seconds + +local const = require("./constants.luau") +local CONTEXT = const.CONTEXT +local ORIGIN = const.ORIGIN +local COOKIE_PATH = const.COOKIE_PATH +local QUICK_PICKS_CACHE_PATH = const.QUICK_PICKS_CACHE_PATH + +local it = require("./innerTube.luau") +local build_auth = it.build_auth + +local thumbs = require("./thumbnails.luau") +local attach_cached_thumbs = thumbs.attach_cached_thumbs +local cache = require("./cache.luau") +local save_library_cache = cache.save_library_cache + +local types = require("../utils/types.luau") + +local EXPO = {} + +local pending_library: any? = nil + +function EXPO.library() + log("library called") + local body_json = noctalia.json.encode({ context = CONTEXT.context, browseId = "FEmusic_liked_playlists" }) or "" + local auth = build_auth() + local auth_header = auth and ("-H \"Authorization: " .. auth .. "\"") or "" + local origin_header = "-H \"Origin: " .. ORIGIN .. "\"" + + local tmp_payload = "/tmp/noctalia-ytmusic-library-payload.json" + local tmp_out = "/tmp/noctalia-ytmusic-library.json" + noctalia.writeFile(tmp_payload, body_json) + + local script = [=[ +export PATH="/etc/profiles/per-user/$USER/bin:$PATH" +C_ARG="" +if [ -s "]=] .. COOKIE_PATH .. [=[" ]; then + C_ARG="--cookie ]=] .. COOKIE_PATH .. [=[" +fi + +curl -s -X POST "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false" \ + -H "Content-Type: application/json" \ + ]=] .. origin_header .. [=[ \ + ]=] .. auth_header .. [=[ \ + $C_ARG \ + -d @"/tmp/noctalia-ytmusic-library-payload.json" > "/tmp/noctalia-ytmusic-library-raw.json" 2>/dev/null + +jq '{ + playlists: [.. | select(.musicTwoRowItemRenderer? != null) | .musicTwoRowItemRenderer | { + id: (.navigationEndpoint.browseEndpoint.browseId // ""), + title: (.title.runs[0].text // ""), + count_text: (.subtitle.runs[0].text // ""), + thumbnail_url: (.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[-1].url // "") + } | select(.id != "" and .title != "")] | unique_by(.id) +}' "/tmp/noctalia-ytmusic-library-raw.json" > "]=] .. tmp_out .. [=[" 2>/dev/null +echo "STATUS=OK" +]=] + + noctalia.runAsync(script, function(res) + local out = (type(res) == "table" and res.stdout) or "" + if out:find("STATUS=OK") and noctalia.fileExists(tmp_out) then + local raw = noctalia.readFile(tmp_out) + local data = noctalia.json.decode(raw or "") + local raw_pls = (type(data) == "table" and type(data.playlists) == "table") and data.playlists or {} + local playlists = {} + for _, item in ipairs(raw_pls) do + local cnt = tonumber(tostring(item.count_text or ""):match("(%d+)")) or 0 + table.insert(playlists, { + id = item.id, + title = item.title, + count = cnt, + thumbnail_url = item.thumbnail_url + }) + end + if #playlists > 0 then + attach_cached_thumbs(playlists) + save_library_cache(playlists) + end + pending_library = playlists + else + pending_library = {} + end + end, 15000) +end + +function EXPO.library_pending() + return pending_library ~= nil +end + +function EXPO.finish_library(cb: (any?, string?) -> ()?) + local playlists = pending_library + pending_library = nil + if cb then cb(playlists or {}) end +end + +function EXPO.playlist_page(playlist_id: string?, continuation_token: string?, cb: (types.PlaylistPageResult?, string?) -> ()) + if type(playlist_id) ~= "string" or playlist_id == "" then + cb({ tracks = {}, next_token = nil }) + return + end + local pid = sanitize(playlist_id) + + local is_cont = continuation_token and continuation_token ~= "" + local body_json + if is_cont then + body_json = noctalia.json.encode({ context = CONTEXT.context, continuation = continuation_token }) or "" + else + body_json = noctalia.json.encode({ context = CONTEXT.context, browseId = pid }) or "" + end + + local auth = build_auth() + local auth_header = auth and ("-H \"Authorization: " .. auth .. "\"") or "" + local origin_header = "-H \"Origin: " .. ORIGIN .. "\"" + + local tmp_payload = "/tmp/noctalia-ytmusic-payload.json" + local tmp_out = "/tmp/noctalia-ytmusic-page.json" + noctalia.writeFile(tmp_payload, body_json) + + local script = [=[ +export PATH="/etc/profiles/per-user/$USER/bin:$PATH" +C_ARG="" +if [ -s "]=] .. COOKIE_PATH .. [=[" ]; then + C_ARG="--cookie ]=] .. COOKIE_PATH .. [=[" +fi + +curl -s -X POST "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false" \ + -H "Content-Type: application/json" \ + ]=] .. origin_header .. [=[ \ + ]=] .. auth_header .. [=[ \ + $C_ARG \ + -d @"/tmp/noctalia-ytmusic-payload.json" > "/tmp/noctalia-ytmusic-raw.json" + +jq '{ + tracks: [.. | .musicResponsiveListItemRenderer? | select(. != null) | { + id: (.playlistItemData.videoId // .navigationEndpoint.watchEndpoint.videoId // ""), + title: (.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + artist: (.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + duration_text: (.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text // ""), + thumbnail_url: (.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[-1].url // "") + } | select(.id != "")], + count_text: ([.. | .musicResponsiveHeaderRenderer? | select(. != null) | (.subtitle?.runs // []) + (.secondSubtitle?.runs // []) | .[]?.text | select(. != null and test("song|track"))] | .[0] // null), + next_token: ([.. | (.continuationCommand?.token // .nextContinuationData?.continuation) | select(. != null and . != "")] | .[0] // null) +}' "/tmp/noctalia-ytmusic-raw.json" > "]=] .. tmp_out .. [=[" +]=] + + noctalia.runAsync(script, function() + if not noctalia.fileExists(tmp_out) then + cb({ tracks = {}, next_token = nil }) + return + end + local raw = noctalia.readFile(tmp_out) + if not raw or raw == "" then + cb({ tracks = {}, next_token = nil }) + return + end + local data = noctalia.json.decode(raw) + if type(data) == "table" then + local tracks = type(data.tracks) == "table" and data.tracks or {} + for _, tr in ipairs(tracks) do + if tr.duration_text and tr.duration_text ~= "" then + tr.duration = fmt_seconds(tr.duration_text) + end + tr.artist = clean_artist(tr.artist) + end + local next_tok = type(data.next_token) == "string" and data.next_token ~= "" and data.next_token or nil + local count_text = type(data.count_text) == "string" and data.count_text or "" + local count_num = tonumber(count_text:match("(%d+)")) + log("jq playlist page got " .. #tracks .. " tracks, next_token=" .. tostring(next_tok ~= nil) .. ", count_num=" .. tostring(count_num)) + if #tracks > 0 then + attach_cached_thumbs(tracks) + end + cb({ tracks = tracks, next_token = next_tok, total_count = count_num }) + else + cb({ tracks = {}, next_token = nil }) + end + end, 15000) +end + +function EXPO.load_cached_quick_picks() + if not noctalia.fileExists(QUICK_PICKS_CACHE_PATH) then return nil end + local raw = noctalia.readFile(QUICK_PICKS_CACHE_PATH) + if not raw or raw == "" then return nil end + local data = noctalia.json.decode(raw) + if type(data) == "table" and #data > 0 then + attach_cached_thumbs(data) + return data + end + return nil +end + +function EXPO.save_quick_picks_cache(tracks: { types.Track }?) + if type(tracks) ~= "table" or #tracks == 0 then return end + local encoded = noctalia.json.encode(tracks) + if encoded then + noctalia.writeFile(QUICK_PICKS_CACHE_PATH, encoded) + end +end + +function EXPO.fetch_quick_picks(callback: ((any?, any?) -> ())?) + local body_json = noctalia.json.encode({ context = CONTEXT.context, browseId = "FEmusic_home" }) or "" + local auth = build_auth() + local auth_header = auth and ("-H \"Authorization: " .. auth .. "\"") or "" + local origin_header = "-H \"Origin: " .. ORIGIN .. "\"" + + local tmp_payload = "/tmp/noctalia-ytmusic-home-payload.json" + local tmp_out = "/tmp/noctalia-ytmusic-quickpicks.json" + noctalia.writeFile(tmp_payload, body_json) + + local script = [=[ +export PATH="/etc/profiles/per-user/$USER/bin:$PATH" +C_ARG="" +if [ -s "]=] .. COOKIE_PATH .. [=[" ]; then + C_ARG="--cookie ]=] .. COOKIE_PATH .. [=[" +fi + +curl -s -X POST "https://music.youtube.com/youtubei/v1/browse?prettyPrint=false" \ + -H "Content-Type: application/json" \ + ]=] .. origin_header .. [=[ \ + ]=] .. auth_header .. [=[ \ + $C_ARG \ + -d @"/tmp/noctalia-ytmusic-home-payload.json" > "/tmp/noctalia-ytmusic-home-raw.json" + +jq '{ + tracks: [.. | select(.musicResponsiveListItemRenderer? != null) | .musicResponsiveListItemRenderer | { + id: (.playlistItemData.videoId // .navigationEndpoint.watchEndpoint.videoId // ""), + title: (.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + artist: (.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + duration_text: (.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text // ""), + thumbnail_url: (.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[-1].url // "") + } | select(.id != "" and .title != "")] | unique_by(.id) | .[0:20], + mixes: [.. | select(.musicTwoRowItemRenderer? != null) | .musicTwoRowItemRenderer | { + id: (.navigationEndpoint.browseEndpoint.browseId // .navigationEndpoint.watchEndpoint.playlistId // ""), + title: (.title.runs[0].text // ""), + subtitle: (.subtitle.runs[0].text // ""), + videoId: (.navigationEndpoint.watchEndpoint.videoId // ""), + thumbnail_url: (.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[-1].url // .thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[-1].url // "") + } | select(.id != "" and .title != "" and .videoId == "" and (.id | test("^VL")) and (.subtitle != "Album") and (.subtitle != "Playlist") and (.subtitle != "Song") and (.subtitle != "Single") and (.subtitle | endswith("subscribers") | not))] | unique_by(.id) | .[0:24] +}' "/tmp/noctalia-ytmusic-home-raw.json" > "]=] .. tmp_out .. [=[" 2>/dev/null +echo "STATUS=OK" +]=] + + noctalia.runAsync(script, function(res) + local out = (type(res) == "table" and res.stdout) or "" + if out:find("STATUS=OK") and noctalia.fileExists(tmp_out) then + local raw = noctalia.readFile(tmp_out) + local data = noctalia.json.decode(raw or "") + local tracks = (type(data) == "table" and type(data.tracks) == "table") and data.tracks or {} + local mixes = (type(data) == "table" and type(data.mixes) == "table") and data.mixes or {} + if #tracks > 0 then + attach_cached_thumbs(tracks) + EXPO.save_quick_picks_cache(tracks) + end + if #mixes > 0 then + math.randomseed(os.time() + #mixes) + for i = #mixes, 2, -1 do + local j = math.random(1, i) + mixes[i], mixes[j] = mixes[j], mixes[i] + end + for i = #mixes, 7, -1 do + table.remove(mixes) + end + attach_cached_thumbs(mixes) + end + if callback then callback(tracks, mixes) end + else + if callback then callback({}, {}) end + end + end, 15000) +end + +return EXPO \ No newline at end of file diff --git a/noctalia-ytmusic/api/cache.luau b/noctalia-ytmusic/api/cache.luau new file mode 100644 index 00000000..23c68241 --- /dev/null +++ b/noctalia-ytmusic/api/cache.luau @@ -0,0 +1,135 @@ +--!strict + +-- on-disk cache read/write: library, playlists, session, recent playlists. + +local logger = require("../utils/log.luau") +local log = logger.module("ytapi", "cache") + +local const = require("./constants.luau") +local LIBRARY_CACHE_PATH = const.LIBRARY_CACHE_PATH +local RECENT_PLAYLISTS_CACHE_PATH = const.RECENT_PLAYLISTS_CACHE_PATH +local PLAYLISTS_CACHE_DIR = const.PLAYLISTS_CACHE_DIR +local SESSION_CACHE_PATH = const.SESSION_CACHE_PATH + +local thumbs = require("./thumbnails.luau") +local attach_cached_thumbs = thumbs.attach_cached_thumbs + +local types = require("../utils/types.luau") + +local EXPO = {} + +function EXPO.load_cached_recent_playlists(): { types.Playlist }? + if not noctalia.fileExists(RECENT_PLAYLISTS_CACHE_PATH) then return nil end + local raw = noctalia.readFile(RECENT_PLAYLISTS_CACHE_PATH) + if not raw or raw == "" then return nil end + local data, _ = noctalia.json.decode(raw) + if type(data) == "table" and #data > 0 then + return data + end + return nil +end + +function EXPO.save_recent_playlists_cache(playlists: { types.Playlist }) + if type(playlists) ~= "table" or #playlists == 0 then return end + local encoded = noctalia.json.encode(playlists) + if encoded then + noctalia.writeFile(RECENT_PLAYLISTS_CACHE_PATH, encoded) + log("saved " .. #playlists .. " playlists to recent_playlists cache") + end +end + +function EXPO.load_cached_library(): { types.Playlist }? + if not noctalia.fileExists(LIBRARY_CACHE_PATH) then return nil end + local raw = noctalia.readFile(LIBRARY_CACHE_PATH) + if not raw or raw == "" then return nil end + local data = noctalia.json.decode(raw) + if type(data) == "table" and #data > 0 then + attach_cached_thumbs(data) + log("loaded " .. #data .. " playlists from library cache") + return data + end + return nil +end + +function EXPO.save_library_cache(playlists: { types.Playlist }) + if type(playlists) ~= "table" or #playlists == 0 then return end + local encoded = noctalia.json.encode(playlists) + if encoded then + noctalia.writeFile(LIBRARY_CACHE_PATH, encoded) + log("saved " .. #playlists .. " playlists to library cache") + end +end + +function EXPO.load_cached_playlist(playlist_id): { types.Track }? + if type(playlist_id) ~= "string" or playlist_id == "" then return nil end + local safe_id = playlist_id:gsub("[^%w%-_]", "_") + local path = PLAYLISTS_CACHE_DIR .. "/" .. safe_id .. ".json" + if not noctalia.fileExists(path) then return nil end + local raw = noctalia.readFile(path) + if not raw or raw == "" then return nil end + local data = noctalia.json.decode(raw) + if type(data) == "table" and #data > 0 then + attach_cached_thumbs(data) + log("loaded " .. #data .. " tracks from cache for playlist " .. playlist_id) + return data + end + return nil +end + +function EXPO.save_playlist_cache(playlist_id, tracks: { types.Track }) + if type(playlist_id) ~= "string" or playlist_id == "" or type(tracks) ~= "table" or #tracks == 0 then return end + local safe_id = playlist_id:gsub("[^%w%-_]", "_") + local path = PLAYLISTS_CACHE_DIR .. "/" .. safe_id .. ".json" + local encoded = noctalia.json.encode(tracks) + if encoded then + noctalia.runAsync("mkdir -p '" .. PLAYLISTS_CACHE_DIR .. "'", function() + noctalia.writeFile(path, encoded) + log("saved " .. #tracks .. " tracks to cache for playlist " .. playlist_id) + end, 5000) + end +end + +function EXPO.load_session(): types.Player? + if not noctalia.fileExists(SESSION_CACHE_PATH) then return nil end + local raw = noctalia.readFile(SESSION_CACHE_PATH) + if not raw or raw == "" then return nil end + local data = noctalia.json.decode(raw) + if type(data) == "table" then + if type(data.queue) == "table" then + attach_cached_thumbs(data.queue) + end + log("loaded session: title='" .. tostring(data.title) .. "' index=" .. tostring(data.index)) + return data + end + return nil +end + +function EXPO.save_session(player: types.Player) + if type(player) ~= "table" then return end + local session = { + id = player.id or "", + title = player.title or "", + artist = player.artist or "", + thumbnail_url = player.thumbnail_url or "", + thumb_path = player.thumb_path or "", + duration = player.duration or 0, + position = player.position or 0, + volume = player.volume or 70, + index = player.index or 0, + queue = player.queue or {} + } + local encoded = noctalia.json.encode(session) + if encoded then + noctalia.writeFile(SESSION_CACHE_PATH, encoded) + end +end + +function EXPO.invalidate_playlist_cache(playlist_id) + if type(playlist_id) ~= "string" or playlist_id == "" then return end + local safe_id = playlist_id:gsub("[^%w%-_]", "_") + local path = PLAYLISTS_CACHE_DIR .. "/" .. safe_id .. ".json" + local script = string.format("rm -f '%s'", path) + noctalia.runAsync(script, function() end, 2000) +end + +return EXPO \ No newline at end of file diff --git a/noctalia-ytmusic/api/constants.luau b/noctalia-ytmusic/api/constants.luau new file mode 100644 index 00000000..b3e3f73d --- /dev/null +++ b/noctalia-ytmusic/api/constants.luau @@ -0,0 +1,38 @@ +--!strict + +-- Shared YT Music API constants. +-- Scope: api/* only. service.luau / panel.luau never touch these directly. + +local XDG_CACHE = noctalia.getenv("XDG_CACHE_HOME") +local CACHE_ROOT = noctalia.expandPath(XDG_CACHE and XDG_CACHE ~= "" and XDG_CACHE or "~/.cache") .. "/noctalia-ytmusic" + +local EXPO = {} + +EXPO.KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" +EXPO.CLIENT_VERSION = "1.20240813.01.00" +EXPO.BASE = "https://music.youtube.com/youtubei/v1/" +EXPO.ORIGIN = "https://music.youtube.com" + +EXPO.CACHE_DIR = CACHE_ROOT +EXPO.COOKIE_PATH = CACHE_ROOT .. "/cookies.txt" +EXPO.LIBRARY_CACHE_PATH = CACHE_ROOT .. "/library.json" +EXPO.RECENT_PLAYLISTS_CACHE_PATH = CACHE_ROOT .. "/recent_playlists.json" +EXPO.PLAYLISTS_CACHE_DIR = CACHE_ROOT .. "/playlists" +EXPO.SESSION_CACHE_PATH = CACHE_ROOT .. "/session.json" +EXPO.QUICK_PICKS_CACHE_PATH = CACHE_ROOT .. "/quick_picks.json" +EXPO.THUMB_DIR = CACHE_ROOT .. "/thumbnails" +EXPO.STREAMS_CACHE_DIR = CACHE_ROOT .. "/streams" +EXPO.AUDIO_DIR = CACHE_ROOT .. "/audio" +EXPO.STATS_CACHE_PATH = CACHE_ROOT .. "/stats.json" + +EXPO.CONTEXT = { + context = { + client = { + clientName = "WEB_REMIX", + clientVersion = EXPO.CLIENT_VERSION, + hl = "en", + }, + }, +} + +return EXPO \ No newline at end of file diff --git a/noctalia-ytmusic/api/innerTube.luau b/noctalia-ytmusic/api/innerTube.luau new file mode 100644 index 00000000..c088973f --- /dev/null +++ b/noctalia-ytmusic/api/innerTube.luau @@ -0,0 +1,224 @@ +--!strict + +export type Response = { + ok: boolean?, + status: number?, + body: string?, +} + +local bit = bit32 + +local logger = require("../utils/log.luau") +local log = logger.module("ytapi", "innerTube") + +local const = require("./constants.luau") +local KEY = const.KEY +local BASE = const.BASE +local ORIGIN = const.ORIGIN +local COOKIE_PATH = const.COOKIE_PATH + +local EXPO = {} + +-- ---------------------------------------------------------------------------- +-- Pure-Luau SHA-1 (FIPS 180-1), uint32 math via bit32 +-- ---------------------------------------------------------------------------- + +local function sha1(msg: string): string + local mod32 = 0xFFFFFFFF + local function hex32(n: number) + local h = "0123456789abcdef" + local out = {} + for i = 1, 8 do + local d = n % 16 + out[9 - i] = h:sub(d + 1, d + 1) + n = math.floor(n / 16) + end + return table.concat(out) + end + local bytes = { string.byte(msg, 1, -1) } + local bits = #bytes * 8 + table.insert(bytes, 0x80) + while #bytes % 64 ~= 56 do + table.insert(bytes, 0) + end + for i = 1, 8 do + local shift = (8 - i) * 8 + table.insert(bytes, math.floor(bits / (2 ^ shift)) % 256) + end + + local h0, h1, h2, h3, h4 = 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0 + + for pos = 1, #bytes, 64 do + local w = {} + for i = 0, 15 do + local o = pos + i * 4 + w[i] = bit.bor( + bit.lshift(bytes[o], 24), + bit.lshift(bytes[o + 1], 16), + bit.lshift(bytes[o + 2], 8), + bytes[o + 3] + ) + end + for i = 16, 79 do + w[i] = bit.lrotate( + bit.bxor(bit.bxor(w[i - 3], w[i - 8]), bit.bxor(w[i - 14], w[i - 16])), + 1 + ) + end + local a, b, c, d, e = h0, h1, h2, h3, h4 + for i = 0, 79 do + local f, k + if i <= 19 then + f = bit.bor(bit.band(b, c), bit.band(bit.bnot(b), d)) + k = 0x5A827999 + elseif i <= 39 then + f = bit.bxor(bit.bxor(b, c), d) + k = 0x6ED9EBA1 + elseif i <= 59 then + f = bit.bor(bit.bor(bit.band(b, c), bit.band(b, d)), bit.band(c, d)) + k = 0x8F1BBCDC + else + f = bit.bxor(bit.bxor(b, c), d) + k = 0xCA62C1D6 + end + local temp = (bit.lrotate(a, 5) + f + e + k + w[i]) % (mod32 + 1) + e, d, c, b, a = d, c, bit.lrotate(b, 30), a, temp + end + h0 = (h0 + a) % (mod32 + 1) + h1 = (h1 + b) % (mod32 + 1) + h2 = (h2 + c) % (mod32 + 1) + h3 = (h3 + d) % (mod32 + 1) + h4 = (h4 + e) % (mod32 + 1) + end + + return hex32(h0) .. hex32(h1) .. hex32(h2) .. hex32(h3) .. hex32(h4) +end + +do + local abc = sha1("abc") + log("sha1 self-test sha1(abc)=" .. abc .. (abc == "a9993e364706816aba3e25717850c26c9cd0d89d" and " OK" or " MISMATCH")) +end + +-- ---------------------------------------------------------------------------- +-- auth + request +-- ---------------------------------------------------------------------------- + +local function load_cookie_values() + local values = {} + local raw = noctalia.readFile(COOKIE_PATH) + if not raw then + log("readFile failed on " .. COOKIE_PATH) + return values + end + log("cookies: path=" .. COOKIE_PATH .. " len=" .. #raw) + for line in raw:gmatch("[^\r\n]+") do + if line:sub(1, 1) ~= "#" then + local fields = {} + for f in line:gmatch("([^\t]+)") do + table.insert(fields, f) + end + if #fields >= 7 and fields[6] == "SAPISID" then + log("cookies: SAPISID line has " .. #fields .. " fields") + end + local name = fields[6] + local value = fields[7] + if name then + values[name] = value + end + end + end + local names = {} + for k in pairs(values) do table.insert(names, k) end + table.sort(names) + log("cookies: parsed " .. #names .. " (" .. table.concat(names, ",") .. "), values not logged") + return values +end + +local auth_state = { ready = false, sapisid = "", cookie = "" } + +function EXPO.invalidate_auth() + auth_state.ready = false +end + +local function build_auth(): (string?, string?) + if not auth_state.ready then + local values = load_cookie_values() + local sapisid = values["SAPISID"] + if not sapisid or sapisid == "" then + return nil, nil + end + local cookieParts = {} + for k, v in pairs(values) do + table.insert(cookieParts, k .. "=" .. v) + end + auth_state.sapisid = sapisid + auth_state.cookie = table.concat(cookieParts, "; ") + auth_state.ready = true + end + local ts = math.floor(noctalia.nowMs() / 1000) + local hash = sha1(ts .. " " .. auth_state.sapisid .. " " .. ORIGIN) + local auth = "SAPISIDHASH " .. ts .. "_" .. hash + return auth, auth_state.cookie +end + +local function innerTube(endpoint: string, request: any, cb: (any?, string?) -> (), defer: boolean?) + local auth, cookie = build_auth() + if not auth then + log("innerTube: no SAPISID auth, request dropped") + cb(nil, "no auth") + return + end + local payload, perr = noctalia.json.encode(request) + if not payload then + log("innerTube: json encode failed " .. tostring(perr)) + cb(nil, tostring(perr)) + return + end + log("innerTube POST " .. endpoint .. " body=" .. #payload .. " bytes") + local accepted = noctalia.http({ + url = BASE .. endpoint .. "?key=" .. KEY .. "&prettyPrint=false", + method = "POST", + body = payload, + headers = { + "Content-Type: application/json", + "Authorization: " .. auth, + "Origin: " .. ORIGIN, + "Cookie: " .. (cookie or ""), + "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + }, + }, function(resp: Response?) + if not resp then + log("innerTube " .. endpoint .. ": no response") + cb(nil, "no response") + return + end + if not resp.ok then + log("innerTube " .. endpoint .. ": http status=" .. tostring(resp.status)) + cb(nil, "http " .. tostring(resp.status)) + return + end + if defer then + log("innerTube " .. endpoint .. ": got " .. #(resp.body or "") .. " bytes (deferred)") + cb(resp.body) + return + end + local data, jerr = noctalia.json.decode(resp.body or "") + if not data then + log("innerTube " .. endpoint .. ": json decode failed " .. tostring(jerr)) + cb(nil, "json " .. tostring(jerr)) + return + end + log("innerTube " .. endpoint .. ": got " .. #(resp.body or "") .. " bytes, parsed ok") + cb(data) + end) + if not accepted then + log("innerTube " .. endpoint .. ": request rejected by host") + cb(nil, "request rejected") + end +end + +EXPO.sha1 = sha1 +EXPO.build_auth = build_auth +EXPO.innerTube = innerTube + +return EXPO \ No newline at end of file diff --git a/noctalia-ytmusic/api/likes.luau b/noctalia-ytmusic/api/likes.luau new file mode 100644 index 00000000..0dbd25de --- /dev/null +++ b/noctalia-ytmusic/api/likes.luau @@ -0,0 +1,94 @@ +--!strict + +-- like / unlike endpoints. + +local helpers = require("../utils/helpers.luau") +local sanitize = helpers.sanitize + +local const = require("./constants.luau") +local CONTEXT = const.CONTEXT +local ORIGIN = const.ORIGIN +local COOKIE_PATH = const.COOKIE_PATH + +local it = require("./innerTube.luau") +local build_auth = it.build_auth + +local EXPO = {} + +function EXPO.like_video(video_id: string?, callback: ((boolean) -> ())?) + if type(video_id) ~= "string" or video_id == "" then + if callback then callback(false) end + return + end + local safe_id = sanitize(video_id) + local body_json = noctalia.json.encode({ + context = CONTEXT.context, + target = { videoId = safe_id } + }) or "" + local auth = build_auth() + local auth_header = auth and ("-H \"Authorization: " .. auth .. "\"") or "" + local origin_header = "-H \"Origin: " .. ORIGIN .. "\"" + local tmp_payload = "/tmp/noctalia-ytmusic-like-payload.json" + noctalia.writeFile(tmp_payload, body_json) + + local script = [=[ +export PATH="/etc/profiles/per-user/$USER/bin:$PATH" +C_ARG="" +if [ -s "]=] .. COOKIE_PATH .. [=[" ]; then + C_ARG="--cookie ]=] .. COOKIE_PATH .. [=[" +fi + +curl -s -X POST "https://music.youtube.com/youtubei/v1/like/like?prettyPrint=false" \ + -H "Content-Type: application/json" \ + ]=] .. origin_header .. [=[ \ + ]=] .. auth_header .. [=[ \ + $C_ARG \ + -d @"/tmp/noctalia-ytmusic-like-payload.json" >/dev/null 2>&1 +echo "STATUS=OK" +]=] + noctalia.runAsync(script, function(res) + local out = (type(res) == "table" and res.stdout) or "" + local ok = out:find("STATUS=OK") ~= nil + if callback then callback(ok) end + end, 10000) +end + +function EXPO.unlike_video(video_id: string?, callback: ((boolean) -> ())?) + if type(video_id) ~= "string" or video_id == "" then + if callback then callback(false) end + return + end + local safe_id = sanitize(video_id) + local body_json = noctalia.json.encode({ + context = CONTEXT.context, + target = { videoId = safe_id } + }) or "" + local auth = build_auth() + local auth_header = auth and ("-H \"Authorization: " .. auth .. "\"") or "" + local origin_header = "-H \"Origin: " .. ORIGIN .. "\"" + local tmp_payload = "/tmp/noctalia-ytmusic-unlike-payload.json" + noctalia.writeFile(tmp_payload, body_json) + + local script = [=[ +export PATH="/etc/profiles/per-user/$USER/bin:$PATH" +C_ARG="" +if [ -s "]=] .. COOKIE_PATH .. [=[" ]; then + C_ARG="--cookie ]=] .. COOKIE_PATH .. [=[" +fi + +curl -s -X POST "https://music.youtube.com/youtubei/v1/like/removelike?prettyPrint=false" \ + -H "Content-Type: application/json" \ + ]=] .. origin_header .. [=[ \ + ]=] .. auth_header .. [=[ \ + $C_ARG \ + -d @"/tmp/noctalia-ytmusic-unlike-payload.json" >/dev/null 2>&1 +echo "STATUS=OK" +]=] + noctalia.runAsync(script, function(res) + local out = (type(res) == "table" and res.stdout) or "" + local ok = out:find("STATUS=OK") ~= nil + if callback then callback(ok) end + end, 10000) +end + +return EXPO \ No newline at end of file diff --git a/noctalia-ytmusic/api/resolve.luau b/noctalia-ytmusic/api/resolve.luau new file mode 100644 index 00000000..20f7a7c3 --- /dev/null +++ b/noctalia-ytmusic/api/resolve.luau @@ -0,0 +1,197 @@ +--!strict + +-- stream resolution, audio download/cache, track stats, prefetch. + +local logger = require("../utils/log.luau") +local log = logger.module("ytapi", "resolve") + +local const = require("./constants.luau") +local COOKIE_PATH = const.COOKIE_PATH +local STREAMS_CACHE_DIR = const.STREAMS_CACHE_DIR +local AUDIO_DIR = const.AUDIO_DIR + +local thumbs = require("./thumbnails.luau") +local get_thumbnail_path = thumbs.get_thumbnail_path + +local types = require("../utils/types.luau") + +local RESOLVE_SCRIPT = [=[ +export PATH="/etc/profiles/per-user/$USER/bin:$PATH" +OUT="${XDG_CACHE_HOME:-$HOME/.cache}/noctalia-ytmusic/cookies.txt" +CACHE_FILE="]=] .. STREAMS_CACHE_DIR .. [=[/VIDEO.url" +if [ -s "$CACHE_FILE" ]; then + URL=$(cat "$CACHE_FILE") + if [ -n "$URL" ]; then + echo "URL=$URL" + exit 0 + fi +fi + +[ -s "$OUT" ] || { echo "NOCACHE=1"; exit 0; } +URL=$(yt-dlp -f bestaudio/best -g --no-playlist --no-warnings --cookies "$OUT" "https://www.youtube.com/watch?v=VIDEO" 2>/dev/null | tail -1) +if [ -n "$URL" ]; then + mkdir -p "]=] .. STREAMS_CACHE_DIR .. [=[" + echo "$URL" > "$CACHE_FILE" + echo "URL=$URL" +else + echo "ERR=1" +fi +]=] + +local function sanitize_str(str) + if type(str) ~= "string" then return "" end + return (str:gsub("[^\32-\126]", ""):gsub("^%s+", ""):gsub("%s+$", "")) +end + +local EXPO = {} + +local stream_cache = {} +local prefetching_ids = {} +local resolving_in_flight = {} + +function EXPO.get_audio_cache_path(video_id: string): string? + if type(video_id) ~= "string" or video_id == "" then return nil end + local safe_id = video_id:gsub("[^%w%-_]", "_") + local path = AUDIO_DIR .. "/" .. safe_id .. ".opus" + if noctalia.fileExists(path) then + return path + end + return nil +end + +function EXPO.get_cached_audio_ids(callback) + local cmd = string.format("mkdir -p '%s' && ls '%s' 2>/dev/null", AUDIO_DIR, AUDIO_DIR) + noctalia.runAsync(cmd, function(res) + local out = (type(res) == "table" and res.stdout) or "" + local set = {} + for id in out:gmatch("([%w%-_]+)%.opus") do + set[id] = true + end + if callback then callback(set) end + end, 5000) +end + +function EXPO.cache_audio(video_id, callback) + if type(video_id) ~= "string" or video_id == "" then + if callback then callback(false) end + return + end + local safe_id = video_id:gsub("[^%w%-_]", "_") + local path = AUDIO_DIR .. "/" .. safe_id .. ".opus" + if noctalia.fileExists(path) then + if callback then callback(true) end + return + end + + local cmd = string.format( + "mkdir -p '%s' && export PATH=\"/etc/profiles/per-user/$USER/bin:$PATH\" && yt-dlp -f bestaudio -o '%s' --cookies '%s' 'https://www.youtube.com/watch?v=%s' >/dev/null 2>&1 && echo 'STATUS=OK'", + AUDIO_DIR, path, COOKIE_PATH, video_id + ) + local launched = noctalia.runAsync(cmd, function(res) + local out = (type(res) == "table" and res.stdout) or "" + local ok = noctalia.fileExists(path) or out:find("STATUS=OK") ~= nil + if callback then callback(ok) end + end, 120000) + if not launched and callback then + callback(false) + end +end + +function EXPO.resolve_stream(video_id: string, cb: ((string?) -> ())?) + if type(video_id) ~= "string" or video_id == "" then + if cb then cb(nil) end + return + end + + local local_audio = EXPO.get_audio_cache_path(video_id) + if local_audio then + log("audio_disk_cache HIT for id=" .. video_id) + if cb then cb(local_audio) end + return + end + + local cached = stream_cache[video_id] + if cached and (os.time() - cached.timestamp) < 14400 then + log("stream_cache HIT for id=" .. video_id) + if cb then cb(cached.url) end + return + end + + local safe_id = video_id:gsub("[^%w%-_]", "_") + local disk_url_path = STREAMS_CACHE_DIR .. "/" .. safe_id .. ".url" + if noctalia.fileExists(disk_url_path) then + local raw_disk = noctalia.readFile(disk_url_path) + if raw_disk and #raw_disk > 20 then + local clean_url = sanitize_str(raw_disk) + stream_cache[video_id] = { url = clean_url, timestamp = os.time() } + log("stream_disk_cache HIT for id=" .. video_id) + if cb then cb(clean_url) end + return + end + end + + if resolving_in_flight[video_id] then + table.insert(resolving_in_flight[video_id], cb) + return + end + + resolving_in_flight[video_id] = { cb } + + log("resolving stream URL for id=" .. video_id) + local script = RESOLVE_SCRIPT:gsub("VIDEO", safe_id) + noctalia.runAsync(script, function(res) + local out = type(res) == "table" and res.stdout or "" + local raw_url = out:match("URL=([^\n\r]+)") + local url = sanitize_str(raw_url) + local callbacks = resolving_in_flight[video_id] or {} + resolving_in_flight[video_id] = nil + + if url ~= "" then + log("stream resolved successfully (" .. #url .. " bytes) for id=" .. video_id) + stream_cache[video_id] = { url = url, timestamp = os.time() } + for _, callback in ipairs(callbacks) do + if callback then callback(url) end + end + else + log("stream resolution failed for id=" .. video_id) + for _, callback in ipairs(callbacks) do + if callback then callback(nil) end + end + end + end, 30000) +end + +function EXPO.prefetch_track(item: types.Track) + if type(item) ~= "table" or not item.id or item.id == "" then return end + local id = item.id + + if item.thumbnail_url or id ~= "" then + local url = item.thumbnail_url or ("https://i.ytimg.com/vi/" .. id .. "/hqdefault.jpg") + get_thumbnail_path(id, url, nil) + end + + if EXPO.get_audio_cache_path(id) then return end + + local cached = stream_cache[id] + if not cached or (os.time() - cached.timestamp) >= 7200 then + prefetching_ids[id] = true + log("prefetching stream URL into memory for id=" .. id) + EXPO.resolve_stream(id, function() + prefetching_ids[id] = nil + end) + end +end + +function EXPO.prefetch_queue(queue: { types.Track }, current_index) + if type(queue) ~= "table" or #queue == 0 then return end + current_index = current_index or 1 + -- Only prefetch 1 track ahead to prevent thread pool exhaustion and UI freezing + for offset = 1, 1 do + local target = queue[current_index + offset] + if target and target.id and target.id ~= "" then + EXPO.prefetch_track(target) + end + end +end + +return EXPO \ No newline at end of file diff --git a/noctalia-ytmusic/api/search.luau b/noctalia-ytmusic/api/search.luau new file mode 100644 index 00000000..16c10f6f --- /dev/null +++ b/noctalia-ytmusic/api/search.luau @@ -0,0 +1,158 @@ +--!strict +--!nolint MultiLineStatement + +local logger = require("../utils/log.luau") +local log = logger.module("ytapi", "search") + +local helpers = require("../utils/helpers.luau") +local sanitize = helpers.sanitize +local clean_artist = helpers.clean_artist +local fmt_seconds = helpers.fmt_seconds + +local const = require("./constants.luau") +local CONTEXT = const.CONTEXT + +local it = require("./innerTube.luau") +local innerTube = it.innerTube + +local types = require("../utils/types.luau") + +local EXPO = {} + +function EXPO.search(query: string?, count: any, cb: ({ types.SearchResult }?, string?) -> (), search_type: string?) + local q: string = tostring(sanitize(query)):gsub("^%s+", ""):gsub("%s+$", "") + count = tonumber(count) or 25 + local st: string = tostring(search_type or "songs") + log("search query='" .. q .. "' type='" .. st .. "'") + if q == "" then + cb({}) + return + end + local params = "EgWKAQIIAWoQChADEAQQCRAFEAoQBRAE" + if st == "playlists" then + params = "EgWKAQIKAWoQChADEAQQCRAFEAoQBRAE" + elseif st == "top" then + params = "" + end + innerTube("search", { + context = CONTEXT.context, + query = q, + params = params, + }, function(raw_body, err) + if not raw_body or raw_body == "" then + cb(nil, err or "empty response") + return + end + local raw_file = "/tmp/noctalia-ytmusic-search-raw.json" + local clean_file = "/tmp/noctalia-ytmusic-search-clean.json" + noctalia.writeFile(raw_file, raw_body) + + local script + if st == "top" then + script = ([=[ +jq ' + ([.. | objects | select(has("musicCardShelfRenderer")) | .musicCardShelfRenderer] | .[0] // null) as $rawcard + | + ([ + .. | objects | select(has("musicCardShelfRenderer")) | .musicCardShelfRenderer | + .contents[]? | + .. | objects | select(has("musicResponsiveListItemRenderer")) | + .musicResponsiveListItemRenderer | + (.playlistItemData.videoId // "") + ] | map(select(. != ""))) as $skip + | + ([$rawcard | select((.onTap.watchEndpoint.videoId // "") != "") | { + id: .onTap.watchEndpoint.videoId, + kind: "song", + title: (.title.runs[0].text // ""), + artist: ([.subtitle.runs[].text] | join("")), + thumbnail_url: (.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[-1].url // "") + }] | .[0]) as $card + | + { + results: reduce ( + ($card // empty), + (.. | select(.musicResponsiveListItemRenderer? != null) | .musicResponsiveListItemRenderer | + ((.playlistItemData.videoId // .overlay.musicItemThumbnailOverlayRenderer.content.musicPlayButtonRenderer.playNavigationEndpoint.watchEndpoint.videoId // .doubleTapCommand.watchEndpoint.videoId // "") as $vid | + (.navigationEndpoint.browseEndpoint.browseId // "") as $bid | + select($vid != "" or $bid != "") | + select(($skip | index($vid)) == null) | + { + id: (if $vid != "" then $vid else $bid end), + kind: (if $vid != "" then "song" else if $bid != "" then "playlist" else "other" end end), + title: (.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + artist: (.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs | map(select(.text != null).text) | join("") // ""), + album: (.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + duration_text: (.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text // ""), + thumbnail_url: (.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[-1].url // "") + })) + ) as $it ([]; if (map(.id) | index($it.id)) then . else . + [$it] end) + } +' "%s" > "%s" 2>/dev/null +echo "STATUS=OK" +]=]):format(raw_file, clean_file) + elseif st == "playlists" then + script = ([=[ +jq '{ + playlists: [.. | select(.musicResponsiveListItemRenderer? != null) | .musicResponsiveListItemRenderer | { + id: (.navigationEndpoint.browseEndpoint.browseId // ""), + title: (.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + artist: (.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs | map(select(.text != null).text) | join("") // ""), + thumbnail_url: (.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[-1].url // "") + } | select(.id != "" and .title != "")] | unique_by(.id) +}' "%s" > "%s" 2>/dev/null +echo "STATUS=OK" +]=]):format(raw_file, clean_file) + else + script = ([=[ +jq '{ + tracks: [.. | select(.musicResponsiveListItemRenderer? != null) | .musicResponsiveListItemRenderer | { + id: (.playlistItemData.videoId // .overlay.musicItemThumbnailOverlayRenderer.content.musicPlayButtonRenderer.playNavigationEndpoint.watchEndpoint.videoId // .doubleTapCommand.watchEndpoint.videoId // ""), + title: (.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + artist: (.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + album: (.flexColumns[2].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text // ""), + duration_text: (.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text // ""), + thumbnail_url: (.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[-1].url // "") + } | select(.id != "" and .title != "")] | unique_by(.id) +}' "%s" > "%s" 2>/dev/null +echo "STATUS=OK" +]=]):format(raw_file, clean_file) + end + + noctalia.runAsync(script, function(res) + local out: { types.SearchResult } = {} + if noctalia.fileExists(clean_file) then + local raw_clean = noctalia.readFile(clean_file) + local data = noctalia.json.decode(raw_clean or "") + local raw_key = (st == "playlists") and "playlists" or ((st == "top") and "results" or "tracks") + local raw_items = (type(data) == "table" and type(data[raw_key]) == "table") and data[raw_key] or {} + for _, tr in ipairs(raw_items) do + if st == "playlists" or (st == "top" and tr.kind == "playlist") then + table.insert(out, { + type = "playlist", + id = tr.id, + title = tr.title, + artist = clean_artist(tr.artist), + thumbnail_url = tr.thumbnail_url + }) + else + table.insert(out, { + id = tr.id, + title = tr.title, + artist = clean_artist(tr.artist), + album = tr.album, + duration = fmt_seconds(tr.duration_text), + duration_text = tr.duration_text, + thumbnail_url = tr.thumbnail_url + }) + end + if #out >= count then break end + end + end + log(string.format("search got %d results type='%s' first='%s'", #out, st, tostring(out[1] and out[1].title))) + cb(out) + end) + end, true) +end + +return EXPO \ No newline at end of file diff --git a/noctalia-ytmusic/api/stats.luau b/noctalia-ytmusic/api/stats.luau new file mode 100644 index 00000000..c972d942 --- /dev/null +++ b/noctalia-ytmusic/api/stats.luau @@ -0,0 +1,111 @@ +--!strict + +-- Track stats (views/likes) fetched via yt-dlp, cached in a single JSON file. + +local logger = require("../utils/log.luau") +local log = logger.module("ytapi", "stats") + +local const = require("./constants.luau") +local STATS_CACHE_PATH = const.STATS_CACHE_PATH + +local STATS_TTL = 24 * 3600 + +local EXPO = {} + +local stats_cache = {} +local stats_in_flight = {} +local cache_loaded = false + +local function load_cache() + if cache_loaded then return end + cache_loaded = true + if not noctalia.fileExists(STATS_CACHE_PATH) then return end + local raw = noctalia.readFile(STATS_CACHE_PATH) + local data = raw and noctalia.json.decode(raw) + if type(data) ~= "table" then return end + for id, entry in pairs(data) do + if type(id) == "string" and type(entry) == "table" then + stats_cache[id] = { + views = tonumber(entry.views) or 0, + likes = tonumber(entry.likes) or 0, + timestamp = tonumber(entry.fetched) or 0, + } + end + end +end + +local function save_cache() + local data = {} + local now = os.time() + for id, entry in pairs(stats_cache) do + if (now - entry.timestamp) < STATS_TTL then + data[id] = { views = entry.views, likes = entry.likes, fetched = entry.timestamp } + else + stats_cache[id] = nil + end + end + local encoded = noctalia.json.encode(data) + if encoded then + noctalia.writeFile(STATS_CACHE_PATH, encoded) + end +end + +function EXPO.track_stats(video_id: string, cb: ((number, number) -> ())?) + if type(video_id) ~= "string" or video_id == "" then + if cb then cb(0, 0) end + return + end + + load_cache() + + local cached = stats_cache[video_id] + if cached and (os.time() - cached.timestamp) < STATS_TTL then + if cb then cb(cached.views, cached.likes) end + return + end + + if stats_in_flight[video_id] then + table.insert(stats_in_flight[video_id], cb) + return + end + stats_in_flight[video_id] = { cb } + + local safe_id = video_id:gsub("[^%w%-_]", "_") + local script = ([=[ +export PATH="/etc/profiles/per-user/$USER/bin:$PATH" +OUT="${XDG_CACHE_HOME:-$HOME/.cache}/noctalia-ytmusic/cookies.txt" +[ -s "$OUT" ] || { echo "NOCACHE=1"; exit 0; } +LINE=$(yt-dlp --skip-download --no-playlist --no-warnings --cookies "$OUT" --print "%(view_count)s|%(like_count)s" "https://www.youtube.com/watch?v=VIDEO" 2>/dev/null | tail -1) +VIEWS=$(printf '%s' "$LINE" | cut -d'|' -f1) +LIKES=$(printf '%s' "$LINE" | cut -d'|' -f2) +case "$VIEWS" in + ''|*[!0-9]*) VIEWS=0;; +esac +case "$LIKES" in + ''|*[!0-9]*) LIKES=0;; +esac +echo "STATS=$VIEWS|$LIKES" +]=]):gsub("VIDEO", safe_id) + + noctalia.runAsync(script, function(res) + local out = type(res) == "table" and res.stdout or "" + local views_likes = out:match("STATS=([%d|]+)") + local views = 0 + local likes = 0 + if views_likes then + local v, l = views_likes:match("^(%d*)|(%d*)$") + views = tonumber(v) or 0 + likes = tonumber(l) or 0 + end + log("stats fetched for id=" .. video_id .. " views=" .. views .. " likes=" .. likes) + stats_cache[video_id] = { views = views, likes = likes, timestamp = os.time() } + save_cache() + local callbacks = stats_in_flight[video_id] or {} + stats_in_flight[video_id] = nil + for _, callback in ipairs(callbacks) do + if callback then callback(views, likes) end + end + end, 30000) +end + +return EXPO diff --git a/noctalia-ytmusic/api/thumbnails.luau b/noctalia-ytmusic/api/thumbnails.luau new file mode 100644 index 00000000..34b0bd6f --- /dev/null +++ b/noctalia-ytmusic/api/thumbnails.luau @@ -0,0 +1,118 @@ +--!strict + +-- thumbnail path/ensure/attach helpers. + +local const = require("./constants.luau") +local THUMB_DIR = const.THUMB_DIR + +local EXPO = {} + +function EXPO.get_thumbnail_path(id: string, url: string?, callback: ((string) -> ())?): string? + if type(id) ~= "string" or id == "" then return nil end + local safe_id = id:gsub("[^%w%-_]", "_") + local path = THUMB_DIR .. "/" .. safe_id .. ".jpg" + + if noctalia.fileExists(path) then + return path + end + + if type(url) == "string" and url ~= "" then + noctalia.runAsync("mkdir -p '" .. THUMB_DIR .. "' && curl -s -L -o '" .. path .. "' '" .. url .. "'", function() + if noctalia.fileExists(path) and callback then + callback(path) + end + end, 10000) + end + + return nil +end + +local known_thumb_files = {} + +local function preload_thumb_cache() + noctalia.runAsync("find /tmp -name 'noctalia-ytmusic-thumb-*.jpg' 2>/dev/null", function(res) + local out = type(res) == "table" and res.stdout or "" + for line in out:gmatch("[^\r\n]+") do + known_thumb_files[line] = true + end + end) +end +preload_thumb_cache() + +function EXPO.ensure_thumbnails(items, on_updated, tag) + if type(items) ~= "table" or #items == 0 then return end + + local missing = {} + local updated_cached = false + + for _, item in ipairs(items) do + if type(item) == "table" and type(item.id) == "string" and item.id ~= "" then + if not item.thumb_path then + local safe_id = item.id:gsub("[^%w%-_]", "_") + local path = THUMB_DIR .. "/" .. safe_id .. ".jpg" + if known_thumb_files[path] then + item.thumb_path = path + updated_cached = true + else + local url = (type(item.thumbnail_url) == "string" and item.thumbnail_url ~= "") and item.thumbnail_url or ("https://i.ytimg.com/vi/" .. item.id .. "/hqdefault.jpg") + table.insert(missing, { item = item, path = path, url = url }) + end + end + end + end + + if #missing == 0 then + if updated_cached and type(on_updated) == "function" then + on_updated() + end + return + end + + local safe_tag = (type(tag) == "string" and tag ~= "") and tag:gsub("[^%w%-_]", "_") or "batch" + local configFile = string.format("/tmp/noctalia-ytmusic-thumb-%s.txt", safe_tag) + local lines = {} + for _, m in ipairs(missing) do + table.insert(lines, string.format("output = \"%s\"\nurl = \"%s\"", m.path, m.url)) + end + noctalia.writeFile(configFile, table.concat(lines, "\n")) + + local script = ([=[ +mkdir -p "%s" +if command -v curl >/dev/null 2>&1; then + curl -s --parallel --parallel-max 16 --parallel-immediate -K "%s" >/dev/null 2>&1 +fi +rm -f "%s" +echo "THUMBS_DONE=1" +]=]):format(THUMB_DIR, configFile, configFile) + + noctalia.runAsync(script, function(res) + local out = (type(res) == "table" and res.stdout) or "" + if out:find("THUMBS_DONE=1") then + for _, m in ipairs(missing) do + known_thumb_files[m.path] = true + m.item.thumb_path = m.path + end + if type(on_updated) == "function" then + on_updated() + end + end + end, 15000) +end + +function EXPO.attach_cached_thumbs(items: { { [string]: any } }) + if type(items) ~= "table" then return end + local count = #items + if count > 50 then count = 50 end + for i = 1, count do + local item = items[i] + if type(item) == "table" and type(item.id) == "string" and item.id ~= "" then + local safe_id = item.id:gsub("[^%w%-_]", "_") + local path = THUMB_DIR .. "/" .. safe_id .. ".jpg" + if noctalia.fileExists(path) then + item.thumb_path = path + end + end + end +end + +return EXPO \ No newline at end of file diff --git a/noctalia-ytmusic/mini.luau b/noctalia-ytmusic/mini.luau new file mode 100644 index 00000000..7ba11d35 --- /dev/null +++ b/noctalia-ytmusic/mini.luau @@ -0,0 +1,292 @@ +local STATE = "noctalia_ytmusic.state" +local theme = require("./utils/theme.luau") +local request = require("./views/actions.luau") + +local state = { + status = "idle", + player = { + status = "stopped", + title = "", + artist = "", + id = "", + position = 0, + duration = 0, + volume = 100, + queue = {}, + index = 0 + } +} + +local seek_drag_state = nil + +local function fmt_time(sec) + sec = tonumber(sec) or 0 + if sec <= 0 then return "0:00" end + local m = math.floor(sec / 60) + local s = math.floor(sec % 60) + return string.format("%d:%02d", m, s) +end + +local function icon_btn(glyph, cb, active) + return ui.button({ + glyph = glyph, + variant = active and "primary" or "ghost", + controlSize = "md", + onClick = cb + }) +end + +local function audio_spec_tooltip() + local player = state.player or {} + local codec_str = (player.codec and player.codec ~= "") and string.upper(player.codec) or "OPUS" + local bitrate_str = (player.bitrate and tonumber(player.bitrate) > 0) and (math.floor(tonumber(player.bitrate) / 1000) .. " kbps") or "256 kbps" + local rate_str = (player.samplerate and tonumber(player.samplerate) > 0) and string.format("%.1f kHz", tonumber(player.samplerate) / 1000) or "48.0 kHz" + local title = (player.title ~= nil and player.title ~= "") and player.title or "Nothing playing" + local artist = (player.artist ~= nil and player.artist ~= "") and player.artist or "—" + return ("%s\n%s\n\nCodec: %s\nBitrate: %s\nSample rate: %s\nOutput driver: PipeWire\nVolume: %d%%") + :format(title, artist, codec_str, bitrate_str, rate_str, tonumber(player.volume) or 70) +end + +local function is_logged_in() + return state.status == "ready" or (state.cookie_count and state.cookie_count > 0) +end + +local function render_mini() + if not is_logged_in() then + panel.render(ui.column({ + key = "mini-login", + align = "center", + justify = "center", + gap = 16, + padding = 24, + flexGrow = 1 + }, { + ui.glyph({ name = "user-x", size = 48, color = "on_surface_variant/0.5" }), + ui.label({ text = "Not Logged In", fontSize = theme.fontHeading, fontWeight = "bold", color = "on_surface" }), + ui.label({ text = "Sign in to YouTube Music to use the mini player.", fontSize = theme.fontBody, color = "on_surface_variant", textAlign = "center" }), + ui.button({ + text = "Open Main Window", + variant = "primary", + controlSize = "md", + onClick = function() noctalia.togglePanel("aabidk20/noctalia-ytmusic:panel") end + }) + })) + return + end + + local player = state.player or {} + local is_playing = (player.status == "playing") + local duration = tonumber(player.duration) or 0 + local position = math.min(tonumber(player.position) or 0, duration) + + if seek_drag_state and seek_drag_state.track_id ~= player.id then + seek_drag_state = nil + end + + local is_drag_active = seek_drag_state and seek_drag_state.track_id == player.id and seek_drag_state.dragging + local slider_val = tonumber((is_drag_active and seek_drag_state.position) or position) or 0 + + local track_title = (player.title ~= nil and player.title ~= "") and player.title or "No Track Playing" + local track_artist = (player.artist ~= nil and player.artist ~= "") and player.artist or "YouTube Music" + + -- Album Art Thumbnail + local player_thumb + if player.thumb_path and noctalia.fileExists(player.thumb_path) then + player_thumb = ui.image({ + path = player.thumb_path, + width = 170, + height = 170, + fit = "cover" + }) + else + player_thumb = ui.row({ + width = 170, + height = 170, + align = "center", + justify = "center", + fill = "surface_variant/0.25", + borderWidth = 0 + }, { + ui.glyph({ name = "music", size = 48, color = is_playing and "primary" or "on_surface_variant" }) + }) + end + + -- Header Cover Card + local cover_card = ui.column({ + key = "mini-cover-card", + align = "center", + justify = "center", + gap = 12, + padding = 6, + fill = "surface_variant/0.15", + borderWidth = 0, + width = "fill" + }, { + player_thumb, + ui.column({ align = "center", gap = 4, width = "fill" }, { + ui.label({ + key = "mini-title", + text = track_title, + fontSize = theme.fontTitle, + fontWeight = "bold", + maxLines = 1, + color = "on_surface", + width = "fill", + textAlign = "center" + }), + ui.column({ key = "mini-artist-wrap", align = "center", gap = 8, width = "fill" }, { + ui.label({ + key = "mini-artist", + text = track_artist, + fontSize = theme.fontTitle, + maxLines = 1, + color = is_playing and "primary" or "on_surface_variant", + width = "fill", + textAlign = "center" + }), + ui.row({ key = "mini-title-actions", align = "center", justify = "center", gap = 4 }, { + (function() + local is_liked = (state.liked_ids and player.id and state.liked_ids[player.id] == true) + return icon_btn("heart", function() + if player.id and player.id ~= "" then + request("toggle_like", { id = player.id }) + end + end, is_liked) + end)(), + ui.button({ + glyph = "activity", + variant = "ghost", + controlSize = "sm", + tooltip = audio_spec_tooltip(), + onHover = function() end + }), + ui.button({ + key = "btn-expand-full", + glyph = "arrows-maximize", + variant = "ghost", + controlSize = "sm", + onClick = function() + noctalia.togglePanel("aabidk20/noctalia-ytmusic:panel") + end + }) + }) + }) + }) + }) + + -- Progress Scrub Bar + local scrub_row = ui.column({ key = "mini-scrub-col", gap = 4, width = "fill" }, { + ui.row({ key = "mini-scrub-labels", align = "center", justify = "space_between", width = "fill" }, { + ui.label({ key = "mini-pos-label", text = fmt_time(slider_val), fontSize = theme.fontCaption, color = "on_surface_variant" }), + ui.label({ key = "mini-dur-label", text = fmt_time(duration), fontSize = theme.fontCaption, color = "on_surface_variant" }) + }), + ui.slider({ + key = "mini-seek-slider", + min = 0, + max = math.max(1, duration), + step = 1, + value = math.floor(slider_val), + controlSize = "sm", + onChange = function(v) + local num = tonumber(v) + -- Ignore programmatic onChange events + if num and num ~= math.floor(position) then + seek_drag_state = { dragging = true, position = num, timestamp = os.time(), track_id = player.id } + end + end, + onDragEnd = function() + local target = (seek_drag_state and seek_drag_state.position) or position + seek_drag_state = nil + request("seek", { seconds = target, value = target }) + end + }) + }) + + -- Transport Controls + local shuffle_active = (player.shuffle == true) + local rep_mode = player.repeat_mode or "off" + local rep_active = (rep_mode ~= "off") + local rep_glyph = (rep_mode == "one") and "repeat-once" or "repeat" + local rep_tooltip = (rep_mode == "one") and "Repeat Track (1)" or ((rep_mode == "all") and "Repeat Queue" or "Repeat Off") + + local is_loading = (player.status == "loading") + local is_playing = (player.status == "playing") + local play_glyph = is_loading and "loader" or (is_playing and "player-pause" or "player-play") + + local controls_row = ui.row({ key = "mini-controls", align = "center", justify = "center", gap = 12, width = "fill" }, { + icon_btn("arrows-shuffle", function() request("toggle_shuffle") end, shuffle_active), + icon_btn("player-skip-back", function() request("prev") end), + ui.button({ + key = "mini-play-btn", + glyph = play_glyph, + variant = "primary", + controlSize = "lg", + onClick = function() request("toggle") end + }), + icon_btn("player-skip-forward", function() request("next") end), + ui.button({ + key = "mini-repeat-btn", + glyph = rep_glyph, + variant = rep_active and "primary" or "ghost", + controlSize = "md", + tooltip = rep_tooltip, + onClick = function() request("cycle_repeat") end + }) + }) + + -- Footer Links & Volume + local footer_row = ui.row({ key = "mini-footer", align = "center", justify = "end", width = "fill", gap = 8 }, { + ui.button({ + key = "btn-open-settings", + glyph = "settings", + variant = "ghost", + controlSize = "sm", + onClick = function() + noctalia.openSettings("aabidk20/noctalia-ytmusic") + end + }) + }) + + panel.render(ui.column({ + key = "mini-root", + gap = 14, + padding = 16, + flexGrow = 1 + }, { + cover_card, + scrub_row, + controls_row, + ui.separator({ key = "mini-sep", color = "outline/0.3" }), + footer_row + })) +end + +local function apply_state(s) + if type(s) ~= "table" then return end + if s.status ~= nil then + state.status = s.status + end + if s.cookie_count ~= nil then + state.cookie_count = s.cookie_count + end + if type(s.player) == "table" then + for k, v in pairs(s.player) do state.player[k] = v end + end + if type(s.liked_ids) == "table" then + state.liked_ids = s.liked_ids + end + render_mini() +end + +function onOpen(_context) + local curr = noctalia.state.get(STATE) + if curr then apply_state(curr) end +end + +if noctalia.state and noctalia.state.watch then + noctalia.state.watch(STATE, apply_state) + local curr = noctalia.state.get(STATE) + if curr then apply_state(curr) end +end + +render_mini() diff --git a/noctalia-ytmusic/modules/auth.luau b/noctalia-ytmusic/modules/auth.luau new file mode 100644 index 00000000..a79d586e --- /dev/null +++ b/noctalia-ytmusic/modules/auth.luau @@ -0,0 +1,154 @@ +local store = require("./store.luau") +local api = require("../api.luau") +local logger = require("../utils/log.luau") +local Bash = require("../utils/bash.luau") +local BROWSERS = require("../utils/browsers.luau") +local library = require("./library.luau") +local home = require("./home.luau") + +local state = store.state +local log = logger.module("ytm", "auth") + +local extraction_active = false + +local Auth = {} + +function Auth.browser_label(id) + for _, b in ipairs(BROWSERS) do + if b.id == id then return b.label end + end + return id or "Browser" +end + +function Auth.open_login(browser) + local target = (type(browser) == "string" and browser ~= "") and browser or "default" + Bash.run_script("cookies.sh", "open_browser", function() end, 5000, target) +end + +function Auth.extract_cookies(browser) + if extraction_active then return end + if type(browser) ~= "string" or browser == "" then return end + local label = Auth.browser_label(browser) + extraction_active = true + state.extracting = true + state.browser = browser + state.browser_label = label + state.status = "extracting" + state.status_text = "Extracting cookies from " .. label .. "..." + state.last_error = "" + store.publish() + + Bash.run_script("cookies.sh", "extract_cookies", function(res) + local out = (type(res) == "table" and res.stdout) or "" + log("extract: browser=" .. tostring(browser) .. " out=" .. tostring(out:sub(1, 200))) + local dir = out:match("DIR=([^\n]+)") or "" + local cookies = out:match("OUT=([^\n]+)") or "" + local total = tonumber(out:match("TOTAL=([^\n]+)") or "0") or 0 + local kept = tonumber(out:match("KEPT=([^\n]+)") or "0") or 0 + local err_log = out:match("LOG=([^\n]*)") or "" + + extraction_active = false + state.extracting = false + + if kept > 0 then + state.status = "ready" + state.temp_dir = dir + state.cookies_path = cookies + state.cookie_count = kept + state.last_error = "" + state.status_text = string.format("Saved %d YouTube cookies from %s (filtered %d others)", + kept, label, math.max(0, total - kept)) + if noctalia.notify then + noctalia.notify("Noctalia YT Music", state.status_text) + end + if api and api.invalidate_auth then + api.invalidate_auth() + end + if api and api.library then + api.library() + end + if api and api.fetch_quick_picks then + api.fetch_quick_picks(function(qp, mixes) + if type(qp) == "table" and #qp > 0 then + state.quick_picks = qp + end + if type(mixes) == "table" and #mixes > 0 then + state.home_mixes = mixes + end + store.publish() + home.fetch_home_thumbs() + end) + end + else + state.status = "error" + state.cookies_path = "" + state.temp_dir = dir + state.cookie_count = 0 + state.last_error = err_log + state.status_text = "No YouTube session found in " .. label + Auth.open_login(browser) + if noctalia.notify then + noctalia.notify("Noctalia YT Music", "No YouTube login in " .. label .. ". Opening YouTube Music — sign in, then click the button again.") + end + end + store.publish() + end, 60000, browser) +end + +function Auth.load_cached_cookies() + Bash.run_script("cookies.sh", "load_cached_cookies", function(res) + local out = (type(res) == "table" and res.stdout) or "" + if out:find("FOUND=1") then + local kept = tonumber(out:match("KEPT=([^\n]+)") or "0") or 0 + local cookies = out:match("OUT=([^\n]+)") or "" + if kept > 0 then + state.status = "ready" + state.cookies_path = cookies + state.cookie_count = kept + state.last_error = "" + if state.status_text == "Not logged in" then + state.status_text = string.format("Loaded %d saved YouTube cookies", kept) + end +if api and api.invalidate_auth then + api.invalidate_auth() + end + if api and api.load_cached_playlist then + local vllm = api.load_cached_playlist("VLLM") + if vllm then library.sync_liked_tracks(vllm) end + end + if api and api.load_cached_quick_picks then + local qp = api.load_cached_quick_picks() + if qp then state.quick_picks = qp end + end + library.sync_audio_cache() + store.publish() + if api and api.fetch_quick_picks then + api.fetch_quick_picks(function(qp, mixes) + if type(qp) == "table" and #qp > 0 then + state.quick_picks = qp + end + if type(mixes) == "table" and #mixes > 0 then + state.home_mixes = mixes + end + store.publish() + home.fetch_home_thumbs() + end) + end + end + end + end, 8000) +end + +function Auth.detect_browsers() + Bash.run_script("cookies.sh", "detect_browsers", function(res) + local out = (type(res) == "table" and res.stdout) or "" + local available = {} + for found in out:gmatch("FOUND=([^\n]+)") do + available[found] = true + end + state.available = available + store.publish() + end, 8000) +end + +return Auth \ No newline at end of file diff --git a/noctalia-ytmusic/modules/cacheops.luau b/noctalia-ytmusic/modules/cacheops.luau new file mode 100644 index 00000000..909c9184 --- /dev/null +++ b/noctalia-ytmusic/modules/cacheops.luau @@ -0,0 +1,45 @@ +local store = require("./store.luau") + +local state = store.state + +-- du -sh output -> { name = size } +local function parse_stats(res) + if type(res) ~= "table" or not res.stdout then return end + local stats = {} + for line in res.stdout:gmatch("[^\r\n]+") do + local size, path = line:match("([^|]+)|(.+)") + if size and path then + local name = path:match("([^/]+)$") + if name then + stats[name] = size + end + end + end + state.cache_stats = stats + store.publish() +end + +local STATS_SCRIPT = [[ + CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/noctalia-ytmusic" + [ -d "$CACHE" ] || exit 0 + du -sh "$CACHE"/audio "$CACHE"/streams "$CACHE"/thumbnails "$CACHE"/playlists 2>/dev/null | awk '{print $1"|"$2}' +]] + +local Cache = {} + +function Cache.fetch_stats() + noctalia.runAsync(STATS_SCRIPT, parse_stats) +end + +function Cache.clear(target) + if not target then return end + noctalia.runAsync(([[ + CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/noctalia-ytmusic" + rm -rf "$CACHE/%s"/* + ]]):format(target), function() + -- After clearing, fetch the stats directly + noctalia.runAsync(STATS_SCRIPT, parse_stats) + end) +end + +return Cache \ No newline at end of file diff --git a/noctalia-ytmusic/modules/downloader.luau b/noctalia-ytmusic/modules/downloader.luau new file mode 100644 index 00000000..0c646773 --- /dev/null +++ b/noctalia-ytmusic/modules/downloader.luau @@ -0,0 +1,97 @@ +local Downloader = {} + +local store = require("./store.luau") +local api = require("../api.luau") +local logger = require("../utils/log.luau") +local library = require("./library.luau") + +local state = store.state +local log = logger.module("ytm", "downloader") + +local publish = store.publish + +local DL_CONCURRENCY = 2 +local queue = {} -- { plid = , id =