mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(security): apply AdminPortFilter to AppleMusic and DownloadActivity controllers, restore Deezer, configure Apple Music env variables
This commit is contained in:
22 files changed
+42
-253
No files matched your search
@@ -98,7 +98,6 @@ public class JavaScriptSyntaxTests
|
||||
var filePath = Path.Combine(_wwwrootPath, "app.js");
|
||||
var content = File.ReadAllText(filePath);
|
||||
|
||||
// Check that the file is now just a deprecation notice
|
||||
Assert.Contains("DEPRECATED", content);
|
||||
Assert.Contains("main.js", content);
|
||||
}
|
||||
@@ -150,8 +149,6 @@ public class JavaScriptSyntaxTests
|
||||
[Fact]
|
||||
public void AppJs_ShouldHaveBalancedBraces()
|
||||
{
|
||||
// app.js is now deprecated and just contains comments
|
||||
// Skip this test or check main.js instead
|
||||
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
|
||||
var content = File.ReadAllText(filePath);
|
||||
|
||||
@@ -164,12 +161,9 @@ public class JavaScriptSyntaxTests
|
||||
[Fact]
|
||||
public void AppJs_ShouldHaveBalancedParentheses()
|
||||
{
|
||||
// app.js is now deprecated and just contains comments
|
||||
// Skip this test or check main.js instead
|
||||
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
|
||||
|
||||
// Use Node.js to validate syntax instead of counting parentheses
|
||||
// This is more reliable than regex-based string/comment removal
|
||||
// Validate syntax with Node.js instead of counting parentheses.
|
||||
string error;
|
||||
var isValid = ValidateJavaScriptSyntax(filePath, out error);
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ using Microsoft.Extensions.Options;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Filters;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/applemusic")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class AppleMusicController : ControllerBase
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
@@ -3,11 +3,13 @@ using allstarr.Models.Download;
|
||||
using allstarr.Services;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using allstarr.Filters;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/downloads")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class DownloadActivityController : ControllerBase
|
||||
{
|
||||
private readonly IEnumerable<IDownloadService> _downloadServices;
|
||||
|
||||
@@ -1529,11 +1529,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Catch-all for any other session-related requests.
|
||||
/// <summary>
|
||||
/// Catch-all proxy for any other session-related endpoints we haven't explicitly implemented.
|
||||
/// This ensures all session management calls get proxied to Jellyfin.
|
||||
/// Examples: GET /Sessions, POST /Sessions/Logout, etc.
|
||||
/// Proxy unhandled session-related endpoints to Jellyfin.
|
||||
/// </summary>
|
||||
[HttpGet("Sessions")]
|
||||
[HttpPost("Sessions")]
|
||||
|
||||
@@ -48,18 +48,6 @@ public partial class JellyfinController
|
||||
searchTerm ?? string.Empty,
|
||||
includeItemTypes ?? string.Empty);
|
||||
|
||||
// ============================================================================
|
||||
// REQUEST ROUTING LOGIC (Priority Order)
|
||||
// ============================================================================
|
||||
// 1. ArtistIds present (external) → Handle external artists (even with ParentId)
|
||||
// 2. AlbumIds present (external) → Handle external albums (even with ParentId)
|
||||
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
|
||||
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter
|
||||
// 5. SearchTerm present → Integrated search (Jellyfin + external sources)
|
||||
// 6. Otherwise → Proxy browse request transparently to Jellyfin
|
||||
// ============================================================================
|
||||
|
||||
// PRIORITY 1: External artist filter - takes precedence over everything (including ParentId)
|
||||
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||
{
|
||||
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
|
||||
@@ -87,7 +75,6 @@ public partial class JellyfinController
|
||||
// If library artist, fall through to handle with ParentId or proxy
|
||||
}
|
||||
|
||||
// PRIORITY 2: External album filter
|
||||
if (!string.IsNullOrWhiteSpace(albumIds))
|
||||
{
|
||||
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
|
||||
@@ -123,7 +110,6 @@ public partial class JellyfinController
|
||||
// If library album, fall through to handle with ParentId or proxy
|
||||
}
|
||||
|
||||
// PRIORITY 3: ParentId present - check if external first
|
||||
if (!string.IsNullOrWhiteSpace(parentId))
|
||||
{
|
||||
// Check if this is an external playlist
|
||||
@@ -165,7 +151,6 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 4: Library artist filter (already checked for external above)
|
||||
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||
{
|
||||
// Library artist - proxy transparently with full query string
|
||||
@@ -177,7 +162,6 @@ public partial class JellyfinController
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
}
|
||||
|
||||
// PRIORITY 5: Search term present - do integrated search (Jellyfin + external)
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
// Check cache for search results (only cache pure searches, not filtered searches)
|
||||
@@ -205,7 +189,6 @@ public partial class JellyfinController
|
||||
|
||||
// Fall through to integrated search below
|
||||
}
|
||||
// PRIORITY 6: No filters, no search - proxy browse request transparently
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
|
||||
|
||||
@@ -469,7 +469,6 @@ public partial class JellyfinController
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// Copies an external track to the kept folder when favorited.
|
||||
/// </summary>
|
||||
@@ -961,11 +960,7 @@ public partial class JellyfinController
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Loads missing tracks from file cache as fallback when Redis is empty.
|
||||
/// <summary>
|
||||
/// Gets a signature (hash) of the Jellyfin playlist to detect changes.
|
||||
/// This is a cheap operation compared to re-matching all tracks.
|
||||
/// Signature includes: track count + concatenated track IDs.
|
||||
/// Gets a signature of the Jellyfin playlist to detect changes.
|
||||
/// </summary>
|
||||
private async Task<string> GetJellyfinPlaylistSignatureAsync(string playlistId)
|
||||
{
|
||||
|
||||
@@ -266,9 +266,4 @@ public class MappingController : ControllerBase
|
||||
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||
_logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
|
||||
/// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP
|
||||
/// </summary>
|
||||
}
|
||||
@@ -554,10 +554,6 @@ public class PlaylistController : ControllerBase
|
||||
}
|
||||
else
|
||||
{
|
||||
// This else block is reached when:
|
||||
// 1. JellyfinId is empty, OR
|
||||
// 2. totalPlayable > 0 (modern path already worked), OR
|
||||
// 3. spotifyTrackCount == 0
|
||||
// Only log if JellyfinId is actually missing
|
||||
if (string.IsNullOrEmpty(config.JellyfinId))
|
||||
{
|
||||
|
||||
@@ -6,23 +6,7 @@ using allstarr.Models.Settings;
|
||||
namespace allstarr.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// REMOVED: Authentication filter for Jellyfin API endpoints.
|
||||
///
|
||||
/// This filter has been removed because Allstarr acts as a TRANSPARENT PROXY.
|
||||
/// Clients authenticate directly with Jellyfin through the proxy, not with the proxy itself.
|
||||
///
|
||||
/// Authentication flow:
|
||||
/// 1. Client sends credentials to /Users/AuthenticateByName
|
||||
/// 2. Proxy forwards request to Jellyfin (no validation)
|
||||
/// 3. Jellyfin validates credentials and returns AccessToken
|
||||
/// 4. Client uses AccessToken in subsequent requests
|
||||
/// 5. Proxy forwards token to Jellyfin for validation
|
||||
///
|
||||
/// The proxy NEVER validates credentials or tokens - that's Jellyfin's job.
|
||||
/// The proxy only forwards authentication headers transparently.
|
||||
///
|
||||
/// If you need to restrict access to the proxy itself, use network-level controls
|
||||
/// (firewall, VPN, reverse proxy with auth) instead of application-level auth.
|
||||
/// Legacy no-op filter retained for compatibility.
|
||||
/// </summary>
|
||||
public class JellyfinAuthFilter : IAsyncActionFilter
|
||||
{
|
||||
@@ -35,11 +19,8 @@ public class JellyfinAuthFilter : IAsyncActionFilter
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
// This filter is now a no-op - all authentication is handled by Jellyfin
|
||||
// Keeping the class for backwards compatibility but it does nothing
|
||||
|
||||
_logger.LogTrace("JellyfinAuthFilter: Transparent proxy mode - no authentication check");
|
||||
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -1028,9 +1028,7 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
|
||||
var isController = base.IsController(typeInfo);
|
||||
if (!isController) return false;
|
||||
|
||||
// All admin controllers should always be registered (for admin UI)
|
||||
// This includes: AdminController, ConfigController, DiagnosticsController, DownloadsController,
|
||||
// PlaylistController, JellyfinAdminController, SpotifyAdminController, LyricsController, MappingController, ScrobblingAdminController
|
||||
// All admin controllers should always be registered for the admin UI.
|
||||
if (typeInfo.Name == "AdminController" ||
|
||||
typeInfo.Name == "AdminAuthController" ||
|
||||
typeInfo.Name == "ConfigController" ||
|
||||
|
||||
@@ -58,12 +58,6 @@ public class RedisPersistenceService : BackgroundService
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss");
|
||||
var snapshotFile = Path.Combine(SnapshotDirectory, $"snapshot_{timestamp}.json");
|
||||
|
||||
// For now, we'll rely on Redis's built-in RDB + AOF persistence
|
||||
// This service is a placeholder for future enhancements like:
|
||||
// - Exporting specific key patterns to JSON
|
||||
// - Creating human-readable backups
|
||||
// - Syncing to external storage
|
||||
|
||||
_logger.LogDebug("Redis snapshot service running (using Redis native persistence)");
|
||||
|
||||
// Clean up old snapshots (keep last 10)
|
||||
|
||||
@@ -7,16 +7,7 @@ using Cronos;
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that fetches playlist tracks directly from Spotify's API.
|
||||
///
|
||||
/// This replaces the Jellyfin Spotify Import plugin dependency with key advantages:
|
||||
/// - Track ordering is preserved (critical for playlists like Release Radar)
|
||||
/// - ISRC codes available for exact matching
|
||||
/// - Real-time data without waiting for plugin sync schedules
|
||||
/// - Full track metadata (duration, release date, etc.)
|
||||
///
|
||||
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
|
||||
/// Cache persists until next cron run to prevent excess Spotify API calls.
|
||||
/// Background service that fetches playlist tracks directly from Spotify on each playlist's cron schedule.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
|
||||
@@ -1100,13 +1100,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns multiple candidate matches with scores for greedy assignment.
|
||||
/// FOLLOWS OPTIMAL ORDER:
|
||||
/// 1. Strip decorators (done in FuzzyMatcher)
|
||||
/// <summary>
|
||||
/// Attempts to match a track by title and artist using fuzzy matching.
|
||||
/// SEARCHES LOCAL FIRST, then external if no local match found.
|
||||
/// Returns multiple candidates for greedy assignment.
|
||||
/// Attempts to match a track by title and artist and returns scored candidates.
|
||||
/// </summary>
|
||||
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||
string title,
|
||||
|
||||
@@ -817,7 +817,6 @@
|
||||
<button class="primary" onclick="saveAllSettings('tab-services')">💾 Save Services Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
<div class="tab-content" id="tab-config">
|
||||
@@ -1008,11 +1007,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar-sticky">
|
||||
<button class="primary" onclick="saveAllSettings('tab-config')">💾 Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="config-backup-card">
|
||||
<h2>Configuration Backup</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;" id="config-backup-description">
|
||||
@@ -1039,6 +1033,10 @@
|
||||
<button class="danger" onclick="restartContainer()">Restart Allstarr</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar-sticky">
|
||||
<button class="primary" onclick="saveAllSettings('tab-config')">💾 Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Issues Tab -->
|
||||
|
||||
@@ -1309,9 +1309,8 @@ async function pollAppleMusicStatus() {
|
||||
const card = document.getElementById("applemusic-manager-card");
|
||||
if (!card) return;
|
||||
|
||||
// Only poll if AppleMusic is the active provider or if we are looking at configuration
|
||||
const currentTab = document.querySelector(".sidebar-link.active")?.getAttribute("data-tab");
|
||||
if (currentTab !== "config") return;
|
||||
if (currentTab !== "services") return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/applemusic/status");
|
||||
@@ -1421,6 +1420,20 @@ async function uploadAppleMusicApk(file) {
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
async function readAppleMusicError(response, fallbackMessage) {
|
||||
const rawText = await response.text();
|
||||
if (!rawText) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(rawText);
|
||||
return payload.detail || payload.error || rawText;
|
||||
} catch {
|
||||
return rawText;
|
||||
}
|
||||
}
|
||||
|
||||
// Login
|
||||
async function submitAppleMusicLogin() {
|
||||
const username = document.getElementById("am-username-input").value.trim();
|
||||
@@ -1440,14 +1453,16 @@ async function submitAppleMusicLogin() {
|
||||
|
||||
if (res.status === 200) {
|
||||
alert("Login successful!");
|
||||
document.getElementById("am-password-input").value = "";
|
||||
document.getElementById("am-tfa-input").value = "";
|
||||
pollAppleMusicStatus();
|
||||
} else if (res.status === 202) {
|
||||
// 2FA required
|
||||
document.getElementById("am-login-section").style.display = "none";
|
||||
document.getElementById("am-tfa-section").style.display = "block";
|
||||
document.getElementById("am-tfa-input").focus();
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert("Login failed: " + text);
|
||||
const message = await readAppleMusicError(res, "Login failed.");
|
||||
alert("Login failed: " + message);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error logging in: " + err.message);
|
||||
@@ -1470,10 +1485,13 @@ async function submitAppleMusic2fa() {
|
||||
|
||||
if (res.ok) {
|
||||
alert("2FA Verification successful! You are now logged in.");
|
||||
document.getElementById("am-password-input").value = "";
|
||||
document.getElementById("am-tfa-input").value = "";
|
||||
document.getElementById("am-tfa-section").style.display = "none";
|
||||
pollAppleMusicStatus();
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert("Verification failed: " + text);
|
||||
const message = await readAppleMusicError(res, "Verification failed.");
|
||||
alert("Verification failed: " + message);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error verifying code: " + err.message);
|
||||
|
||||
@@ -1014,7 +1014,7 @@ textarea {
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 32px -24px -24px -24px;
|
||||
margin: 32px -24px 0 -24px;
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
z-index: 100;
|
||||
|
||||
@@ -2531,7 +2531,6 @@ def _clean_stsd_content(
|
||||
version_flags = stsd_content[:4]
|
||||
entry_count = struct.unpack(">I", stsd_content[4:8])[0]
|
||||
|
||||
# Parse and clean each sample entry.
|
||||
cleaned_entries = []
|
||||
offset = 8
|
||||
|
||||
@@ -2547,9 +2546,7 @@ def _clean_stsd_content(
|
||||
|
||||
entry_data = stsd_content[offset : offset + entry_size]
|
||||
|
||||
# Check if this is an encrypted entry
|
||||
if entry_type in (b"enca", b"encv", b"encs", b"encm"):
|
||||
# Clean the encrypted entry
|
||||
cleaned_entry = _clean_encrypted_sample_entry(entry_data)
|
||||
cleaned_entries.append(cleaned_entry)
|
||||
else:
|
||||
@@ -2568,7 +2565,6 @@ def _clean_stsd_content(
|
||||
else:
|
||||
cleaned_entries = [cleaned_entries[0]]
|
||||
|
||||
# Rebuild stsd content
|
||||
result = version_flags + struct.pack(">I", len(cleaned_entries))
|
||||
for entry in cleaned_entries:
|
||||
result += entry
|
||||
@@ -2614,7 +2610,6 @@ def _clean_encrypted_sample_entry(entry_data: bytes) -> bytes:
|
||||
if len(entry_data) < sample_entry_header_size:
|
||||
return entry_data
|
||||
|
||||
# Find the original format from sinf/frma
|
||||
original_format = _find_original_format(entry_data)
|
||||
if not original_format:
|
||||
# If we can't find frma, try common mappings
|
||||
@@ -2681,7 +2676,6 @@ def _find_original_format(entry_data: bytes) -> Optional[bytes]:
|
||||
if frma_size != 12: # frma is always 12 bytes: size(4) + type(4) + format(4)
|
||||
return None
|
||||
|
||||
# Extract the original format
|
||||
return sinf_data[frma_idx + 4 : frma_idx + 8]
|
||||
|
||||
|
||||
@@ -2696,11 +2690,9 @@ def _remove_sinf_from_entry(entry_data: bytes) -> bytes:
|
||||
if len(entry_data) < sample_entry_header_size:
|
||||
return entry_data
|
||||
|
||||
# Check if sinf exists
|
||||
if b"sinf" not in entry_data:
|
||||
return entry_data
|
||||
|
||||
# Rebuild entry without sinf
|
||||
new_entry = entry_data[:sample_entry_header_size]
|
||||
|
||||
child_offset = sample_entry_header_size
|
||||
@@ -2725,12 +2717,10 @@ def _remove_sinf_from_entry(entry_data: bytes) -> bytes:
|
||||
|
||||
def _extract_alac_config(data: bytes) -> Optional[bytes]:
|
||||
"""Extract ALAC configuration from moov/stsd box (for backwards compatibility)."""
|
||||
# Simple search for 'alac' box in data
|
||||
idx = data.find(b"alac")
|
||||
if idx < 4:
|
||||
return None
|
||||
|
||||
# Check if it's inside stsd (look for full structure)
|
||||
# The 'alac' cookie box follows the sample entry
|
||||
alac_idx = idx
|
||||
while alac_idx < len(data) - 100:
|
||||
|
||||
@@ -12,15 +12,12 @@ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("gamdl-aio")
|
||||
|
||||
# Add gamdl to sys.path
|
||||
PROJECT_ROOT = Path(__file__).parent.resolve()
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "gamdl"))
|
||||
|
||||
# Import gamdl modules
|
||||
try:
|
||||
from gamdl.api import AppleMusicApi
|
||||
from gamdl.api.wrapper import WrapperApi
|
||||
@@ -65,7 +62,6 @@ class Login2FARequest(BaseModel):
|
||||
async def start_wrapper_daemon():
|
||||
global wrapper_proc, wrapper_api, apple_music_api
|
||||
|
||||
# Check if native libraries are staged
|
||||
sentinel = SYSTEM_LIBS_DIR / "libandroidappmusic.so"
|
||||
if not sentinel.exists():
|
||||
logger.warning(f"Native Apple libraries not staged at {sentinel}. Wrapper daemon cannot start yet.")
|
||||
@@ -73,7 +69,6 @@ async def start_wrapper_daemon():
|
||||
|
||||
logger.info("Starting wrapper-v2 daemon...")
|
||||
|
||||
# Configure env for wrapper launcher
|
||||
env = os.environ.copy()
|
||||
env["HTTP_PORT"] = str(HTTP_PORT)
|
||||
env["TARGET_ARCH"] = TARGET_ARCH
|
||||
@@ -88,7 +83,6 @@ async def start_wrapper_daemon():
|
||||
return False
|
||||
|
||||
try:
|
||||
# Start wrapper daemon as supervisor
|
||||
wrapper_proc = subprocess.Popen(
|
||||
[str(wrapper_bin)],
|
||||
cwd=str(WRAPPER_DIR),
|
||||
@@ -98,11 +92,9 @@ async def start_wrapper_daemon():
|
||||
text=True
|
||||
)
|
||||
|
||||
# Monitor startup output
|
||||
await asyncio.sleep(2)
|
||||
logger.info("wrapper-v2 daemon started successfully.")
|
||||
|
||||
# Initialize Wrapper API and Apple Music API
|
||||
await init_apple_music_api()
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -217,7 +209,6 @@ async def login_2fa(req: Login2FARequest):
|
||||
|
||||
@app.post("/api/setup")
|
||||
async def upload_apk(file: UploadFile = File(...)):
|
||||
# Save uploaded file
|
||||
temp_apk = DATA_DIR / file.filename
|
||||
try:
|
||||
with open(temp_apk, "wb") as buffer:
|
||||
@@ -225,14 +216,12 @@ async def upload_apk(file: UploadFile = File(...)):
|
||||
|
||||
logger.info(f"Received APK file: {temp_apk.name}. Extracting libraries...")
|
||||
|
||||
# Run stage system libraries first
|
||||
stage_cmd = ["bash", "tools/stage-system.sh", "--arch", TARGET_ARCH]
|
||||
stage_res = subprocess.run(stage_cmd, cwd=str(WRAPPER_DIR), capture_output=True, text=True)
|
||||
if stage_res.returncode != 0:
|
||||
logger.error(f"stage-system failed: {stage_res.stderr}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to stage system libs: {stage_res.stderr}")
|
||||
|
||||
# Run extract libraries
|
||||
extract_cmd = [
|
||||
"bash", "tools/extract-libs.sh",
|
||||
"--bundle", str(temp_apk),
|
||||
@@ -244,10 +233,8 @@ async def upload_apk(file: UploadFile = File(...)):
|
||||
logger.error(f"extract-libs failed: {extract_res.stderr}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to extract Apple libs: {extract_res.stderr}")
|
||||
|
||||
# Clean up uploaded APK file to save disk space
|
||||
temp_apk.unlink()
|
||||
|
||||
# Start/Restart the daemon
|
||||
global wrapper_proc
|
||||
if wrapper_proc:
|
||||
wrapper_proc.terminate()
|
||||
@@ -266,7 +253,6 @@ async def search(q: str, type: str = "song", limit: int = 20):
|
||||
raise HTTPException(status_code=401, detail="Apple Music subscription not authenticated")
|
||||
|
||||
try:
|
||||
# Map types
|
||||
results = []
|
||||
if type == "song":
|
||||
res = await apple_music_api.get_search_results(q, limit=limit, types="songs")
|
||||
@@ -324,7 +310,6 @@ async def get_song(track_id: str):
|
||||
s = song_data[0]
|
||||
attrs = s["attributes"]
|
||||
|
||||
# Extract metadata
|
||||
return {
|
||||
"id": s["id"],
|
||||
"title": attrs["name"],
|
||||
@@ -348,12 +333,10 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
|
||||
if not apple_music_api or not apple_music_api.active_subscription:
|
||||
raise HTTPException(status_code=401, detail="Apple Music subscription not authenticated")
|
||||
|
||||
# Setup temporary directory for download/transcode
|
||||
temp_dir = DATA_DIR / f"temp_{track_id}"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
try:
|
||||
# Determine codec matching the requested quality
|
||||
codec_priority = [SongCodec.ALAC]
|
||||
if quality == "alac-16-44":
|
||||
codec_priority = [SongCodec.ALAC_16_44]
|
||||
@@ -368,7 +351,6 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
|
||||
|
||||
logger.info(f"Downloading stream for track {track_id} with codec {codec_priority[0].value}")
|
||||
|
||||
# Instantiate gamdl interfaces programmatically
|
||||
base_interface = await AppleMusicBaseInterface.create(
|
||||
apple_music_api=apple_music_api,
|
||||
cover_format=CoverFormat.JPG,
|
||||
@@ -403,7 +385,6 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
|
||||
no_synced_lyrics=True,
|
||||
)
|
||||
|
||||
# Get download items and execute
|
||||
url = f"https://music.apple.com/us/song/{track_id}"
|
||||
download_queue = []
|
||||
async for item in downloader.get_download_item_from_url(url):
|
||||
@@ -415,12 +396,10 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
|
||||
item = download_queue[0]
|
||||
await downloader.download(item)
|
||||
|
||||
# Find the decrypted M4A file
|
||||
m4a_path = Path(item.staged_path)
|
||||
if not m4a_path.exists():
|
||||
raise FileNotFoundError("M4A download file was not generated.")
|
||||
|
||||
# Transcode on-the-fly to FLAC
|
||||
flac_path = temp_dir / f"{track_id}.flac"
|
||||
logger.info(f"Transcoding {m4a_path.name} to FLAC...")
|
||||
|
||||
@@ -439,8 +418,6 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
|
||||
logger.error(f"FFmpeg failed: {res.stderr.decode('utf-8')}")
|
||||
raise Exception("FFmpeg transcode failed")
|
||||
|
||||
# Return streaming file response
|
||||
# Background task cleans up files AFTER response is sent
|
||||
def cleanup():
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
@@ -461,7 +438,6 @@ async def stream_audio(track_id: str, quality: str = "alac-16-44"):
|
||||
shutil.rmtree(temp_dir)
|
||||
raise HTTPException(status_code=500, detail=f"Streaming failed: {str(e)}")
|
||||
|
||||
# Serve API status on root
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"name": "Gamdl All-in-One", "status": "online"}
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
// Token-harvest helpers.
|
||||
//
|
||||
// Post-login, Apple's `RequestContext` carries the iTunes-side auth
|
||||
// state (DSID + signed device blobs). We use that to:
|
||||
// - Read the storefront identifier directly from RequestContext.
|
||||
// - GET sf-api-token-service.itunes.apple.com/apiToken?clientId=musicAndroid
|
||||
// &version=1 for the developer token (JWT; JSON key `token`).
|
||||
// - POST to play.itunes.apple.com/.../createMusicToken for the
|
||||
// Music User Token (MusicKit-side token derived from this login).
|
||||
// - Decode the `dsid` claim out of the dev-token JWT.
|
||||
//
|
||||
// Everything goes through Apple's URLRequest so requests are
|
||||
// transparently signed with X-Dsid / X-Token / X-iTunes-Storefront
|
||||
// headers; we don't have to reimplement that. The URL signing
|
||||
// machinery lives in libstoreservicescore.so and consumes the same
|
||||
// RequestContext that AuthenticateFlow just populated.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
@@ -29,10 +12,6 @@ struct Tokens;
|
||||
|
||||
namespace tokens {
|
||||
|
||||
// One-shot orchestrator used by the worker thread. Populates the
|
||||
// storefront / dev_token / music_user_token / dsid fields on `out`
|
||||
// (the caller is responsible for apple_id and logged_in_at). Returns
|
||||
// false on the first hard failure with stderr-logged context.
|
||||
bool harvest_all(const Symbols& s,
|
||||
abi::shared_ptr req_ctx,
|
||||
abi::shared_ptr device_guid,
|
||||
|
||||
@@ -1,39 +1,3 @@
|
||||
// wrapper-v2 daemon entry point.
|
||||
//
|
||||
// The daemon starts in LoggedOut state, expects credentials via HTTP,
|
||||
// and drives Apple's AuthenticateFlow under the hood:
|
||||
//
|
||||
// GET /health
|
||||
// GET /me
|
||||
// POST /login body: { "apple_id": "...", "password": "..." }
|
||||
// POST /login/2fa body: { "code": "123456" }
|
||||
// DELETE /login
|
||||
//
|
||||
// Persistence: mount WRAPPER_BASE_DIR so Apple keeps mpl_db across
|
||||
// restarts. After a prior POST /login (or first-time -L style login),
|
||||
// startup may restore tokens from that session without password
|
||||
// (WRAPPER_RESTORE_SESSION=1, default).
|
||||
//
|
||||
// Configuration is environment-only. Optional argv: --help. All knobs
|
||||
// use the WRAPPER_ prefix:
|
||||
//
|
||||
// WRAPPER_HOST Bind address (default 0.0.0.0)
|
||||
// WRAPPER_PORT Bind port (default 80)
|
||||
// WRAPPER_MODE supervisor (default) or worker
|
||||
// WRAPPER_WORKER_PORT Private supervisor->worker port (default 18080)
|
||||
// WRAPPER_BASE_DIR Apple-lib working dir (default
|
||||
// /data/data/com.apple.android.music/files)
|
||||
// WRAPPER_DEVICE_INFO 9-tuple device identifier
|
||||
// WRAPPER_APPLE_INIT "0" to skip Apple lib init at startup
|
||||
// (useful for /health-only smoke tests).
|
||||
// WRAPPER_RESTORE_SESSION "0" skips on-disk session restore after init.
|
||||
// WRAPPER_APPLE_ID Optional label for GET /me apple_id after restore
|
||||
// (not sent to Apple).
|
||||
// WRAPPER_USERNAME With WRAPPER_PASSWORD, run password sign-in at
|
||||
// startup if not already authenticated (same as
|
||||
// POST /login username field — Apple ID email).
|
||||
// WRAPPER_PASSWORD App-specific password for WRAPPER_USERNAME auto-login.
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
|
||||
@@ -286,10 +286,6 @@ Server::Server(httplib::Server& svr,
|
||||
: svr_(svr), rt_(rt), loader_(loader), account_(account), info_(std::move(info)) {}
|
||||
|
||||
void Server::mount() {
|
||||
// ---- GET /health ----
|
||||
// Liveness + runtime debug info. Always returns 200 if the
|
||||
// process is up; consumers should treat runtime.initialized==false
|
||||
// as a soft failure (auth/decrypt endpoints won't work) rather than a hard one.
|
||||
svr_.Get("/health", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("GET", req);
|
||||
json runtime = {
|
||||
@@ -312,11 +308,6 @@ void Server::mount() {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- GET /me ----
|
||||
// Combined daemon snapshot: version, runtime probe (same facts as
|
||||
// /health.runtime), and auth (Apple ID state + harvested tokens after
|
||||
// a successful POST /login). iTunes account-token / X-Token are NOT
|
||||
// exposed — only dev_token, music_user_token, storefront, dsid.
|
||||
svr_.Get("/me", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("GET", req);
|
||||
if (rt_.initialized() && restore_session_enabled()) {
|
||||
@@ -330,17 +321,6 @@ void Server::mount() {
|
||||
respond_json(res, 200, std::move(body));
|
||||
});
|
||||
|
||||
// ---- POST /login ----
|
||||
// Body: { "username": "...", "password": "..." }
|
||||
// or { "apple_id": "...", "password": "..." } (synonyms)
|
||||
// Returns:
|
||||
// 200 if AuthenticateFlow completed (state=authenticated, tokens present)
|
||||
// 202 if Apple asked for HSA2 (state=awaiting_2fa) - follow up with
|
||||
// POST /login/2fa
|
||||
// 401 if Apple rejected credentials (state=failed)
|
||||
// 409 if a login is already in progress
|
||||
// 503 if the runtime is not initialized
|
||||
// 504 if the flow has not produced any state inside kLoginTimeout
|
||||
svr_.Post("/login", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("POST", req);
|
||||
if (!rt_.initialized()) {
|
||||
@@ -415,9 +395,6 @@ void Server::mount() {
|
||||
respond_json(res, http_status_for(state), snapshot_to_json(account_.public_snapshot()));
|
||||
});
|
||||
|
||||
// ---- POST /login/2fa ----
|
||||
// Body: { "code": "123456" }
|
||||
// Returns 200 / 401 / 409 / 504 with the same shape as /login.
|
||||
svr_.Post("/login/2fa", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("POST", req);
|
||||
json body;
|
||||
@@ -456,15 +433,6 @@ void Server::mount() {
|
||||
respond_json(res, http_status_for(state), snapshot_to_json(account_.public_snapshot()));
|
||||
});
|
||||
|
||||
// ---- GET /playback ----
|
||||
// Returns Apple's full MZ-protocol playback dispatch as native JSON
|
||||
// (the CFDictionary plist tree walked into nlohmann::json: dict ->
|
||||
// object, array -> array, string/number/bool -> matching JSON types,
|
||||
// CFData -> base64 string, CFDate -> ISO 8601). Driven by
|
||||
// storeservicescore::PurchaseRequest with urlBagKey="subDownload"
|
||||
// (matching upstream wrapper's get_m3u8_method_download). Unlike
|
||||
// upstream which extracts just the last asset's URL, we hand back
|
||||
// every flavor / key URI / metadata field Apple included.
|
||||
svr_.Get("/playback", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("GET", req);
|
||||
if (!rt_.initialized()) {
|
||||
@@ -522,15 +490,6 @@ void Server::mount() {
|
||||
respond_json(res, 200, std::move(pr.body));
|
||||
});
|
||||
|
||||
// ---- POST /decrypt ----
|
||||
// FairPlay sample decrypt. Binary request:
|
||||
// u32be adam_id_len, u32be uri_len, u32be sample_count,
|
||||
// u32be sample_len[sample_count], adam_id bytes, uri bytes,
|
||||
// concatenated ciphertext samples.
|
||||
// Binary response:
|
||||
// u32be sample_count, u32be sample_len[sample_count],
|
||||
// concatenated plaintext samples.
|
||||
// Requires authenticated + playback_ready.
|
||||
svr_.Post("/decrypt", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("POST", req);
|
||||
if (!rt_.initialized()) {
|
||||
@@ -593,10 +552,6 @@ void Server::mount() {
|
||||
res.set_content(response_body, "application/octet-stream");
|
||||
});
|
||||
|
||||
// ---- DELETE /login ----
|
||||
// Clears in-memory tokens and (if a flow is running) signals the
|
||||
// worker thread to abort. Apple's kvs.sqlitedb cache is NOT
|
||||
// touched; the next POST /login will reuse it if still valid.
|
||||
svr_.Delete("/login", [this](const httplib::Request& req, httplib::Response& res) {
|
||||
access_log("DELETE", req);
|
||||
auto prev = account_.state();
|
||||
@@ -607,7 +562,6 @@ void Server::mount() {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- exception fallback ----
|
||||
svr_.set_exception_handler([](const httplib::Request& req, httplib::Response& res,
|
||||
std::exception_ptr ep) {
|
||||
std::string what = "unknown";
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
// HTTP server wiring.
|
||||
//
|
||||
// The Server class owns an httplib::Server and mounts the routes
|
||||
// wrapper-v2 exposes. References to the runtime modules are
|
||||
// captured by reference - the Server does not own them.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
@@ -17,12 +11,8 @@
|
||||
namespace wrapper {
|
||||
|
||||
struct ServerInfo {
|
||||
// Free-form version string surfaced via /health and /me.
|
||||
std::string version = "0.0.1";
|
||||
|
||||
// True iff Apple lib initialization is enabled (controlled by
|
||||
// WRAPPER_APPLE_INIT). Surfaced via /health so it is obvious when
|
||||
// the daemon is running in stub-only mode.
|
||||
bool apple_init_enabled = true;
|
||||
};
|
||||
|
||||
@@ -34,7 +24,6 @@ public:
|
||||
apple::Account& account,
|
||||
ServerInfo info);
|
||||
|
||||
// Mount all routes onto the underlying httplib::Server.
|
||||
void mount();
|
||||
|
||||
private:
|
||||
|
||||
Reference in new issue
Block a user