mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 08:01:36 +00:00
332060b8e3
Replace the mixed legacy Konsta presentation and fixed light surfaces with the shared Sky UI system. Add FlipTok comment replies and reactions, editable profile media, follower lists, external sound metadata, and the required schema migration.
707 lines
26 KiB
Lua
707 lines
26 KiB
Lua
Bridge.Database.AfterMigration("sky_phone", function()
|
|
local server_tracks = {}
|
|
local server_tracks_by_id = {}
|
|
local configured_track_ids = {}
|
|
local music_asset_directory = "config/music"
|
|
local music_asset_prefix = music_asset_directory .. "/"
|
|
local music_asset_extensions = {
|
|
audio = { "ogg", "mp3" },
|
|
artwork = { "webp", "png", "jpg", "jpeg" },
|
|
}
|
|
local music_asset_types = {}
|
|
|
|
for kind, extensions in pairs(music_asset_extensions) do
|
|
for _, extension in ipairs(extensions) do
|
|
music_asset_types[extension] = kind
|
|
end
|
|
end
|
|
|
|
local function trim(value)
|
|
return type(value) == "string" and value:match("^%s*(.-)%s*$") or nil
|
|
end
|
|
|
|
local function text_length(value)
|
|
return type(value) == "string" and utf8.len(value) or nil
|
|
end
|
|
|
|
local function optional_text(value)
|
|
local normalized = trim(value)
|
|
return normalized ~= "" and normalized or nil
|
|
end
|
|
|
|
local function safe_music_asset_name(name)
|
|
return type(name) == "string"
|
|
and name ~= ""
|
|
and name ~= "."
|
|
and name ~= ".."
|
|
and not name:find("[/\\]")
|
|
and not name:find("%c")
|
|
end
|
|
|
|
local function add_music_asset(index, relative_path, file_name)
|
|
local stem, extension = file_name:match("^(.+)%.([^.]+)$")
|
|
extension = extension and extension:lower() or nil
|
|
local kind = extension and music_asset_types[extension] or nil
|
|
if not stem or not kind then
|
|
return
|
|
end
|
|
|
|
local id = stem:lower()
|
|
local by_id = index[kind][id]
|
|
if not by_id then
|
|
by_id = {}
|
|
index[kind][id] = by_id
|
|
end
|
|
|
|
local candidates = by_id[extension]
|
|
if not candidates then
|
|
candidates = {}
|
|
by_id[extension] = candidates
|
|
end
|
|
candidates[#candidates + 1] = music_asset_prefix .. relative_path
|
|
end
|
|
|
|
local function discover_music_assets()
|
|
local index = { audio = {}, artwork = {} }
|
|
if type(io.readdir) ~= "function" then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Automatic music discovery requires io.readdir. Update the FXServer artifacts.",
|
|
{ always = true }
|
|
)
|
|
return nil
|
|
end
|
|
|
|
local mount_root = ("@%s/%s"):format(GetCurrentResourceName(), music_asset_directory)
|
|
local visited_entries = 0
|
|
local failure_reason = nil
|
|
|
|
local function walk(mount_path, relative_directory, depth)
|
|
if failure_reason then
|
|
return true
|
|
end
|
|
if depth > 32 then
|
|
failure_reason = "directory nesting exceeds 32 levels"
|
|
return true
|
|
end
|
|
|
|
local directory = io.readdir(mount_path)
|
|
if not directory then
|
|
failure_reason = ("could not read '%s'"):format(mount_path)
|
|
return true
|
|
end
|
|
|
|
local names = {}
|
|
local has_entries = false
|
|
for name in directory:lines() do
|
|
has_entries = true
|
|
if safe_music_asset_name(name) then
|
|
names[#names + 1] = name
|
|
else
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Skipped unsafe music path entry '%s'.",
|
|
tostring(name),
|
|
{ always = true }
|
|
)
|
|
end
|
|
end
|
|
directory:close()
|
|
table.sort(names)
|
|
|
|
for _, name in ipairs(names) do
|
|
if visited_entries >= 10000 then
|
|
failure_reason = "more than 10000 files and directories were found"
|
|
return true
|
|
end
|
|
visited_entries = visited_entries + 1
|
|
local relative_path = relative_directory == ""
|
|
and name
|
|
or (relative_directory .. "/" .. name)
|
|
local asset_path = mount_path .. "/" .. name
|
|
|
|
-- Cfx returns an empty directory listing for regular files. Walking every
|
|
-- entry is portable; io.open cannot distinguish directories on Linux.
|
|
if not walk(asset_path, relative_path, depth + 1) then
|
|
add_music_asset(index, relative_path, name)
|
|
end
|
|
end
|
|
|
|
return has_entries
|
|
end
|
|
|
|
local success, scan_error = pcall(walk, mount_root, "", 0)
|
|
if not success then
|
|
failure_reason = tostring(scan_error)
|
|
end
|
|
if failure_reason then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Music asset discovery failed; no server tracks were loaded: %s",
|
|
failure_reason,
|
|
{ always = true }
|
|
)
|
|
return nil
|
|
end
|
|
|
|
return index
|
|
end
|
|
|
|
local function resolve_music_asset(index, id, kind)
|
|
local by_extension = index[kind][id:lower()]
|
|
if not by_extension then
|
|
return nil, "missing"
|
|
end
|
|
|
|
for _, extension in ipairs(music_asset_extensions[kind]) do
|
|
local candidates = by_extension[extension]
|
|
if candidates then
|
|
table.sort(candidates)
|
|
if #candidates > 1 then
|
|
return nil, "ambiguous", candidates
|
|
end
|
|
return candidates[1]
|
|
end
|
|
end
|
|
|
|
return nil, "missing"
|
|
end
|
|
|
|
local function music_asset_url(path)
|
|
local encoded_path = path:gsub("([^A-Za-z0-9%-%._~/])", function(character)
|
|
return ("%%%02X"):format(character:byte())
|
|
end)
|
|
return ("https://cfx-nui-%s/%s"):format(GetCurrentResourceName(), encoded_path)
|
|
end
|
|
|
|
local function affected_rows(result)
|
|
if type(result) == "number" then
|
|
return result
|
|
end
|
|
return type(result) == "table" and tonumber(result.affectedRows) or 0
|
|
end
|
|
|
|
local playlist_add_locks = {}
|
|
|
|
local function with_playlist_add_lock(playlist_id, callback)
|
|
local previous_lock = playlist_add_locks[playlist_id]
|
|
local current_lock = promise.new()
|
|
playlist_add_locks[playlist_id] = current_lock
|
|
|
|
if previous_lock then
|
|
Citizen.Await(previous_lock)
|
|
end
|
|
|
|
local success, result = xpcall(callback, debug.traceback)
|
|
current_lock:resolve(true)
|
|
if playlist_add_locks[playlist_id] == current_lock then
|
|
playlist_add_locks[playlist_id] = nil
|
|
end
|
|
if not success then
|
|
error(result, 0)
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function uuid()
|
|
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
|
|
local id = rows[1] and rows[1].id
|
|
if type(id) ~= "string" then
|
|
error("[sky_phone] Database did not generate a music id.")
|
|
end
|
|
return id
|
|
end
|
|
|
|
local function normalize_server_tracks()
|
|
local music_assets = discover_music_assets()
|
|
if not music_assets then
|
|
return
|
|
end
|
|
|
|
for index, configured in ipairs(Config.Music.Tracks or {}) do
|
|
local id = type(configured) == "table" and trim(configured.Id) or nil
|
|
local title = type(configured) == "table" and trim(configured.Title) or nil
|
|
local artist = type(configured) == "table" and trim(configured.Artist) or nil
|
|
local title_length = text_length(title)
|
|
local artist_length = text_length(artist)
|
|
|
|
if not id
|
|
or not id:match("^[A-Za-z0-9_-]+$")
|
|
or #id > 48
|
|
or not title_length
|
|
or title_length < 1
|
|
or title_length > 160
|
|
or not artist_length
|
|
or artist_length < 1
|
|
or artist_length > 120
|
|
then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Ignored invalid Config.Music.Tracks entry at index %s. Only Id, Title, and Artist are required.",
|
|
tostring(index),
|
|
{ always = true }
|
|
)
|
|
elseif configured_track_ids[id:lower()] then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Ignored duplicate Config.Music.Tracks Id '%s' at index %s. IDs are case-insensitive.",
|
|
id,
|
|
tostring(index),
|
|
{ always = true }
|
|
)
|
|
else
|
|
configured_track_ids[id:lower()] = true
|
|
local file, file_error, file_candidates = resolve_music_asset(music_assets, id, "audio")
|
|
local artwork, artwork_error, artwork_candidates = resolve_music_asset(music_assets, id, "artwork")
|
|
|
|
if file_error == "missing" then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Ignored music track '%s': no %s.ogg or %s.mp3 was found under config/music.",
|
|
id,
|
|
id,
|
|
id,
|
|
{ always = true }
|
|
)
|
|
elseif file_error == "ambiguous" then
|
|
Bridge.Debug(
|
|
"error",
|
|
"[sky_phone] Ignored music track '%s': multiple equally preferred audio files were found: %s",
|
|
id,
|
|
table.concat(file_candidates, ", "),
|
|
{ always = true }
|
|
)
|
|
else
|
|
if artwork_error == "ambiguous" then
|
|
Bridge.Debug(
|
|
"warn",
|
|
"[sky_phone] Music track '%s' was loaded without artwork because multiple equally preferred images were found: %s",
|
|
id,
|
|
table.concat(artwork_candidates, ", "),
|
|
{ always = true }
|
|
)
|
|
artwork = nil
|
|
end
|
|
|
|
local track = {
|
|
id = id,
|
|
source = "server",
|
|
title = title,
|
|
artist = artist,
|
|
url = music_asset_url(file),
|
|
artwork = artwork and music_asset_url(artwork) or nil,
|
|
}
|
|
server_tracks[#server_tracks + 1] = track
|
|
server_tracks_by_id[id] = track
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
normalize_server_tracks()
|
|
|
|
local function session_owner(source)
|
|
local session, error_response = SkyPhone.RequireSession(source)
|
|
if not session then
|
|
return nil, nil, error_response
|
|
end
|
|
local account = SkyPhone.RequireAccount(source)
|
|
return account and account.id or nil, session.imei
|
|
end
|
|
|
|
local function owner_condition(account_id, imei, alias)
|
|
local prefix = alias and ("`" .. alias .. "`.") or ""
|
|
if account_id then
|
|
return prefix .. "`account_id` = ?", { account_id }
|
|
end
|
|
return prefix .. "`account_id` IS NULL AND " .. prefix .. "`device_imei` = ?", { imei }
|
|
end
|
|
|
|
local function append_params(target, values)
|
|
for _, value in ipairs(values) do
|
|
target[#target + 1] = value
|
|
end
|
|
end
|
|
|
|
local function youtube_track_dto(row)
|
|
local video_id = row.video_id
|
|
return {
|
|
id = row.id,
|
|
source = "youtube",
|
|
videoId = video_id,
|
|
title = row.title,
|
|
artist = row.artist,
|
|
artwork = "https://i.ytimg.com/vi/" .. video_id .. "/hqdefault.jpg",
|
|
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
|
|
}
|
|
end
|
|
|
|
local function list_youtube_tracks(account_id, imei)
|
|
local condition, params = owner_condition(account_id, imei)
|
|
local rows = Bridge.Database.Query(([[
|
|
SELECT `id`, `video_id`, `title`, `artist`,
|
|
UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
|
|
FROM `sky_phone_music_youtube_songs`
|
|
WHERE %s
|
|
ORDER BY `created_at` DESC, `id`
|
|
]]):format(condition), params)
|
|
local tracks = {}
|
|
for index, row in ipairs(rows) do
|
|
tracks[index] = youtube_track_dto(row)
|
|
end
|
|
return tracks
|
|
end
|
|
|
|
local function list_playlists(account_id, imei)
|
|
local condition, params = owner_condition(account_id, imei)
|
|
local rows = Bridge.Database.Query(([[
|
|
SELECT `id`, `name`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix`
|
|
FROM `sky_phone_music_playlists`
|
|
WHERE %s
|
|
ORDER BY `updated_at` DESC, `created_at` DESC
|
|
]]):format(condition), params)
|
|
local playlists = {}
|
|
local playlists_by_id = {}
|
|
for index, row in ipairs(rows) do
|
|
local playlist = {
|
|
id = row.id,
|
|
name = row.name,
|
|
entries = {},
|
|
createdAt = (tonumber(row.created_at_unix) or 0) * 1000,
|
|
}
|
|
playlists[index] = playlist
|
|
playlists_by_id[row.id] = playlist
|
|
end
|
|
|
|
if #rows == 0 then
|
|
return playlists
|
|
end
|
|
|
|
local item_condition, item_params = owner_condition(account_id, imei, "p")
|
|
local items = Bridge.Database.Query(([[
|
|
SELECT `i`.`playlist_id`, `i`.`source`, `i`.`song_id`
|
|
FROM `sky_phone_music_playlist_items` AS `i`
|
|
INNER JOIN `sky_phone_music_playlists` AS `p` ON `p`.`id` = `i`.`playlist_id`
|
|
WHERE %s
|
|
ORDER BY `i`.`position`, `i`.`id`
|
|
]]):format(item_condition), item_params)
|
|
for _, item in ipairs(items) do
|
|
local playlist = playlists_by_id[item.playlist_id]
|
|
if playlist then
|
|
playlist.entries[#playlist.entries + 1] = {
|
|
source = item.source,
|
|
songId = item.song_id,
|
|
}
|
|
end
|
|
end
|
|
return playlists
|
|
end
|
|
|
|
local function bootstrap(account_id, imei)
|
|
return {
|
|
serverTracks = server_tracks,
|
|
youtubeTracks = list_youtube_tracks(account_id, imei),
|
|
playlists = list_playlists(account_id, imei),
|
|
}
|
|
end
|
|
|
|
local function owned_playlist(account_id, imei, playlist_id)
|
|
local condition, owner_params = owner_condition(account_id, imei)
|
|
local params = { playlist_id }
|
|
append_params(params, owner_params)
|
|
local rows = Bridge.Database.Query(([[
|
|
SELECT `id` FROM `sky_phone_music_playlists`
|
|
WHERE `id` = ? AND %s LIMIT 1
|
|
]]):format(condition), params)
|
|
return rows[1] ~= nil
|
|
end
|
|
|
|
local function valid_personal_song(account_id, imei, song_id)
|
|
local condition, owner_params = owner_condition(account_id, imei)
|
|
local params = { song_id }
|
|
append_params(params, owner_params)
|
|
local rows = Bridge.Database.Query(([[
|
|
SELECT `id` FROM `sky_phone_music_youtube_songs`
|
|
WHERE `id` = ? AND %s LIMIT 1
|
|
]]):format(condition), params)
|
|
return rows[1] ~= nil
|
|
end
|
|
|
|
Bridge.Callbacks.Register("sky_phone:music:bootstrap", function(source)
|
|
local account_id, imei, error_response = session_owner(source)
|
|
if not imei then
|
|
return error_response
|
|
end
|
|
return { success = true, data = bootstrap(account_id, imei) }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:music:add-youtube", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local account_id, imei, error_response = session_owner(source)
|
|
if not imei then
|
|
return error_response
|
|
end
|
|
local payload = type(data) == "table" and data or {}
|
|
local video_id = SkyPhoneYouTube.ParseId(payload.url)
|
|
if not video_id then
|
|
return { success = false, error = "invalid_youtube_url" }
|
|
end
|
|
local custom_title = optional_text(payload.title)
|
|
local custom_artist = optional_text(payload.artist)
|
|
local custom_title_length = text_length(custom_title)
|
|
local custom_artist_length = text_length(custom_artist)
|
|
if (custom_title and (not custom_title_length or custom_title_length > 160))
|
|
or (custom_artist and (not custom_artist_length or custom_artist_length > 120))
|
|
then
|
|
return { success = false, error = "invalid_song_metadata" }
|
|
end
|
|
|
|
local condition, owner_params = owner_condition(account_id, imei)
|
|
local duplicate_params = { video_id }
|
|
append_params(duplicate_params, owner_params)
|
|
local duplicate = Bridge.Database.Query(([[
|
|
SELECT `id` FROM `sky_phone_music_youtube_songs`
|
|
WHERE `video_id` = ? AND %s LIMIT 1
|
|
]]):format(condition), duplicate_params)
|
|
if duplicate[1] then
|
|
return { success = false, error = "song_exists" }
|
|
end
|
|
local count_rows = Bridge.Database.Query(([[
|
|
SELECT COUNT(*) AS `count` FROM `sky_phone_music_youtube_songs` WHERE %s
|
|
]]):format(condition), owner_params)
|
|
if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.Music.MaximumPersonalSongs then
|
|
return { success = false, error = "song_limit" }
|
|
end
|
|
|
|
local metadata = (not custom_title or not custom_artist)
|
|
and SkyPhoneYouTube.FetchMetadata(video_id)
|
|
or nil
|
|
local title = custom_title or metadata and metadata.title or "YouTube " .. video_id
|
|
local artist = custom_artist or metadata and metadata.artist or "YouTube"
|
|
local id = uuid()
|
|
if account_id then
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_music_youtube_songs`
|
|
(`id`, `account_id`, `device_imei`, `video_id`, `title`, `artist`)
|
|
VALUES (?, ?, NULL, ?, ?, ?)
|
|
]], { id, account_id, video_id, title, artist })
|
|
else
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_music_youtube_songs`
|
|
(`id`, `account_id`, `device_imei`, `video_id`, `title`, `artist`)
|
|
VALUES (?, NULL, ?, ?, ?, ?)
|
|
]], { id, imei, video_id, title, artist })
|
|
end
|
|
return { success = true, data = bootstrap(account_id, imei) }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:music:remove-youtube", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local account_id, imei, error_response = session_owner(source)
|
|
if not imei then
|
|
return error_response
|
|
end
|
|
local song_id = type(data) == "table" and data.id or nil
|
|
if type(song_id) ~= "string" or not valid_personal_song(account_id, imei, song_id) then
|
|
return { success = false, error = "song_not_found" }
|
|
end
|
|
|
|
local condition, owner_params = owner_condition(account_id, imei, "p")
|
|
local item_params = { song_id }
|
|
append_params(item_params, owner_params)
|
|
Bridge.Database.Query(([[
|
|
DELETE `i` FROM `sky_phone_music_playlist_items` AS `i`
|
|
INNER JOIN `sky_phone_music_playlists` AS `p` ON `p`.`id` = `i`.`playlist_id`
|
|
WHERE `i`.`source` = 'youtube' AND `i`.`song_id` = ? AND %s
|
|
]]):format(condition), item_params)
|
|
|
|
local song_condition, song_owner_params = owner_condition(account_id, imei)
|
|
local delete_params = { song_id }
|
|
append_params(delete_params, song_owner_params)
|
|
Bridge.Database.Query(([[
|
|
DELETE FROM `sky_phone_music_youtube_songs` WHERE `id` = ? AND %s
|
|
]]):format(song_condition), delete_params)
|
|
return { success = true, data = bootstrap(account_id, imei) }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:music:create-playlist", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local account_id, imei, error_response = session_owner(source)
|
|
if not imei then
|
|
return error_response
|
|
end
|
|
local name = trim(type(data) == "table" and data.name or nil)
|
|
local name_length = text_length(name)
|
|
if not name_length or name_length < 1 or name_length > Config.Music.PlaylistNameMaxLength then
|
|
return { success = false, error = "invalid_playlist" }
|
|
end
|
|
|
|
local condition, owner_params = owner_condition(account_id, imei)
|
|
local count_rows = Bridge.Database.Query(([[
|
|
SELECT COUNT(*) AS `count` FROM `sky_phone_music_playlists` WHERE %s
|
|
]]):format(condition), owner_params)
|
|
if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.Music.MaximumPlaylists then
|
|
return { success = false, error = "playlist_limit" }
|
|
end
|
|
|
|
local id = uuid()
|
|
if account_id then
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_music_playlists` (`id`, `account_id`, `device_imei`, `name`)
|
|
VALUES (?, ?, NULL, ?)
|
|
]], { id, account_id, name })
|
|
else
|
|
Bridge.Database.Query([[
|
|
INSERT INTO `sky_phone_music_playlists` (`id`, `account_id`, `device_imei`, `name`)
|
|
VALUES (?, NULL, ?, ?)
|
|
]], { id, imei, name })
|
|
end
|
|
return { success = true, data = bootstrap(account_id, imei) }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:music:rename-playlist", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local account_id, imei, error_response = session_owner(source)
|
|
if not imei then
|
|
return error_response
|
|
end
|
|
local playlist_id = type(data) == "table" and data.id or nil
|
|
local name = trim(type(data) == "table" and data.name or nil)
|
|
local name_length = text_length(name)
|
|
if type(playlist_id) ~= "string"
|
|
or not name_length
|
|
or name_length < 1
|
|
or name_length > Config.Music.PlaylistNameMaxLength
|
|
then
|
|
return { success = false, error = "invalid_playlist" }
|
|
end
|
|
|
|
if not owned_playlist(account_id, imei, playlist_id) then
|
|
return { success = false, error = "playlist_not_found" }
|
|
end
|
|
local condition, owner_params = owner_condition(account_id, imei)
|
|
local params = { name, playlist_id }
|
|
append_params(params, owner_params)
|
|
Bridge.Database.Query(([[
|
|
UPDATE `sky_phone_music_playlists` SET `name` = ?
|
|
WHERE `id` = ? AND %s
|
|
]]):format(condition), params)
|
|
return { success = true, data = bootstrap(account_id, imei) }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:music:delete-playlist", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local account_id, imei, error_response = session_owner(source)
|
|
if not imei then
|
|
return error_response
|
|
end
|
|
local playlist_id = type(data) == "table" and data.id or nil
|
|
if type(playlist_id) ~= "string" then
|
|
return { success = false, error = "invalid_playlist" }
|
|
end
|
|
local condition, owner_params = owner_condition(account_id, imei)
|
|
local params = { playlist_id }
|
|
append_params(params, owner_params)
|
|
local result = Bridge.Database.Query(([[
|
|
DELETE FROM `sky_phone_music_playlists` WHERE `id` = ? AND %s
|
|
]]):format(condition), params)
|
|
if affected_rows(result) ~= 1 then
|
|
return { success = false, error = "playlist_not_found" }
|
|
end
|
|
return { success = true, data = bootstrap(account_id, imei) }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:music:add-to-playlist", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local account_id, imei, error_response = session_owner(source)
|
|
if not imei then
|
|
return error_response
|
|
end
|
|
local playlist_id = type(data) == "table" and data.playlistId or nil
|
|
local source_type = type(data) == "table" and data.source or nil
|
|
local song_id = type(data) == "table" and data.songId or nil
|
|
if type(playlist_id) ~= "string"
|
|
or type(song_id) ~= "string"
|
|
or not owned_playlist(account_id, imei, playlist_id)
|
|
or (source_type ~= "server" and source_type ~= "youtube")
|
|
or (source_type == "server" and not server_tracks_by_id[song_id])
|
|
or (source_type == "youtube" and not valid_personal_song(account_id, imei, song_id))
|
|
then
|
|
return { success = false, error = "invalid_song" }
|
|
end
|
|
|
|
local mutation_error = with_playlist_add_lock(playlist_id, function()
|
|
local counts = Bridge.Database.Query([[
|
|
SELECT COUNT(*) AS `count`, COALESCE(MAX(`position`), 0) AS `last_position`,
|
|
COALESCE(MAX(`source` = ? AND `song_id` = ?), 0) AS `already_exists`
|
|
FROM `sky_phone_music_playlist_items` WHERE `playlist_id` = ?
|
|
]], { source_type, song_id, playlist_id })
|
|
if tonumber(counts[1] and counts[1].already_exists) == 1 then
|
|
return "song_already_in_playlist"
|
|
end
|
|
if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Music.MaximumPlaylistSongs then
|
|
return "playlist_song_limit"
|
|
end
|
|
local insert_result = Bridge.Database.Query([[
|
|
INSERT IGNORE INTO `sky_phone_music_playlist_items`
|
|
(`playlist_id`, `source`, `song_id`, `position`)
|
|
VALUES (?, ?, ?, ?)
|
|
]], {
|
|
playlist_id,
|
|
source_type,
|
|
song_id,
|
|
(tonumber(counts[1] and counts[1].last_position) or 0) + 1,
|
|
})
|
|
if affected_rows(insert_result) ~= 1 then
|
|
return "song_already_in_playlist"
|
|
end
|
|
Bridge.Database.Query(
|
|
"UPDATE `sky_phone_music_playlists` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?",
|
|
{ playlist_id }
|
|
)
|
|
return nil
|
|
end)
|
|
if mutation_error then
|
|
return { success = false, error = mutation_error }
|
|
end
|
|
return { success = true, data = bootstrap(account_id, imei) }
|
|
end)
|
|
|
|
Bridge.Callbacks.Register("sky_phone:music:remove-from-playlist", function(source, data)
|
|
if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then
|
|
return { success = false, error = "rate_limited" }
|
|
end
|
|
local account_id, imei, error_response = session_owner(source)
|
|
if not imei then
|
|
return error_response
|
|
end
|
|
local playlist_id = type(data) == "table" and data.playlistId or nil
|
|
local source_type = type(data) == "table" and data.source or nil
|
|
local song_id = type(data) == "table" and data.songId or nil
|
|
if type(playlist_id) ~= "string"
|
|
or type(song_id) ~= "string"
|
|
or (source_type ~= "server" and source_type ~= "youtube")
|
|
or not owned_playlist(account_id, imei, playlist_id)
|
|
then
|
|
return { success = false, error = "invalid_song" }
|
|
end
|
|
Bridge.Database.Query([[
|
|
DELETE FROM `sky_phone_music_playlist_items`
|
|
WHERE `playlist_id` = ? AND `source` = ? AND `song_id` = ?
|
|
]], { playlist_id, source_type, song_id })
|
|
return { success = true, data = bootstrap(account_id, imei) }
|
|
end)
|
|
end)
|