// Copyright (c) Arlo Godfrey. All Rights Reserved.
// Licensed under the GNU Lesser General Public License, Version 3.0 with additional terms.
// See the LICENSE, LICENSE.LESSER and LICENSE.ADDITIONAL files in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using System.Xml.Serialization;
using OwlCore.AbstractStorage;
using OwlCore.Extensions;
using StrixMusic.Sdk.FileMetadata.Models;
using StrixMusic.Sdk.FileMetadata.Models.Playlist.Smil;
namespace StrixMusic.Sdk.FileMetadata.Scanners
{
public partial class PlaylistMetadataScanner
{
///
/// Scans playlist file for metadata.
///
/// The most folder that we have permission to access files.
/// The path to the file.
/// The relevant files to link data to.
/// Fully scanned .
public static async Task ScanPlaylistMetadata(IFolderData rootFolder, IFileData playlistFile, IEnumerable files)
{
PlaylistMetadata? playlistMetadata;
switch (playlistFile.FileExtension)
{
case ".zpl":
case ".wpl":
case ".smil":
playlistMetadata = await GetSmilMetadata(playlistFile, files);
break;
case ".m3u":
case ".m3u8":
case ".vlc":
playlistMetadata = await GetM3UMetadata(playlistFile, files);
break;
case ".xspf":
playlistMetadata = await GetXspfMetadata(playlistFile, files);
break;
case ".asx":
playlistMetadata = await GetAsxMetadata(playlistFile, files);
break;
case ".mpcpl":
playlistMetadata = await GetMpcplMetadata(playlistFile, files);
break;
case ".fpl":
playlistMetadata = await GetFplMetadata(playlistFile, files);
break;
case ".pls":
playlistMetadata = await GetPlsMetadata(playlistFile, files);
break;
case ".aimppl4":
playlistMetadata = await GetAimpplMetadata(playlistFile, files);
break;
default:
// Format not supported
return null;
}
return playlistMetadata;
}
///
/// Gets tracks from the SMIL metatdata in and links them to the given .
///
/// The path to the file.
/// The scanned metadata files.
/// Recognizes Zune's ZPL and WMP's WPL.
private static async Task GetSmilMetadata(IFileData playlistFile, IEnumerable files)
{
var ser = new XmlSerializer(typeof(Smil));
using var stream = await playlistFile.GetStreamAsync();
using var xmlReader = new XmlTextReader(stream);
var smil = ser.Deserialize(xmlReader) as Smil;
var playlist = new PlaylistMetadata
{
Id = playlistFile.Path.HashMD5Fast(),
};
var mediaList = smil?.Body?.Seq?.Media?.ToList();
playlist.Title = smil?.Head?.Title;
if (mediaList == null)
return null;
playlist.TrackIds = new HashSet();
foreach (var media in mediaList)
{
if (media.Src != null)
{
if (Uri.TryCreate(media.Src, UriKind.RelativeOrAbsolute, out Uri uri))
{
var hash = TryGetHashFromExistingTracks(uri, files);
if (hash != null)
{
playlist.Duration ??= default;
playlist.TrackIds.Add(media.Src.HashMD5Fast());
}
}
}
playlist.TotalTrackCount++;
}
return playlist;
}
///
/// Gets tracks from the M3U metatdata in and links them to the given .
///
/// The path to the file.
/// The relevant files to link data to.
/// Recognizes both M3U (default encoding) and M3U8 (UTF-8 encoding).
private static async Task GetM3UMetadata(IFileData playlistFile, IEnumerable files)
{
using var stream = await playlistFile.GetStreamAsync();
using var content = playlistFile.FileExtension == ".m3u8" ? new StreamReader(stream, Encoding.UTF8) : new StreamReader(stream);
var playlist = new PlaylistMetadata()
{
Id = playlistFile.Path.HashMD5Fast(),
};
while (!content.EndOfStream)
{
var line = await content.ReadLineAsync();
// Handle M3U directives
if (line[0] == '#')
{
// --++ Extended M3U ++--
// Playlist display title
if (line.StartsWith("#PLAYLIST:", StringComparison.InvariantCulture))
{
playlist.Title = line.Split(':')[1];
}
}
else
{
if (Uri.TryCreate(line, UriKind.RelativeOrAbsolute, out Uri uri))
{
var hash = TryGetHashFromExistingTracks(uri, files);
if (hash != null)
{
playlist.TrackIds ??= new HashSet();
playlist.TrackIds.Add(hash);
}
}
}
}
playlist.Title ??= playlistFile.Name; // If the title is null, filename is assigned because if a playlist has no title its not visible to the user on UI.
return playlist;
}
///
/// Gets tracks from the XSPF metatdata in and links them to the given .
///
/// Does not support any application extensions.
/// The file to scan for metadata.
/// The scanned metadata files.
private static async Task GetXspfMetadata(IFileData playlistFile, IEnumerable files)
{
using var stream = await playlistFile.GetStreamAsync();
var doc = XDocument.Load(stream);
var xmlRoot = doc.Root;
var xmlns = xmlRoot.GetDefaultNamespace().NamespaceName;
var trackList = xmlRoot.Element(XName.Get("trackList", xmlns));
var trackListElements = trackList.Elements(XName.Get("track", xmlns));
var listElements = trackListElements as XElement[] ?? trackListElements.ToArray();
var playlist = new PlaylistMetadata()
{
Id = playlistFile.Path.HashMD5Fast(),
Title = xmlRoot.Element(XName.Get("title", xmlns))?.Value,
TotalTrackCount = listElements.Length,
Description = xmlRoot.Element(XName.Get("annotation", xmlns))?.Value,
};
var url = xmlRoot.Element(XName.Get("info", xmlns))?.Value;
if (url != null)
playlist.Url = new Uri(url);
foreach (var media in listElements)
{
var location = media.Element(XName.Get("location", xmlns))?.Value;
if (location != null)
{
if (Uri.TryCreate(location, UriKind.RelativeOrAbsolute, out Uri localPath))
{
playlist.TrackIds ??= new HashSet();
localPath = new Uri(location);
var hash = TryGetHashFromExistingTracks(localPath, files);
if (hash != null)
playlist.TrackIds.Add(hash);
}
}
}
return playlist;
}
///
/// Gets tracks from the ASX metatdata in and links them to the given .
///
/// The file to scan for metadata.
/// The scanned metadata files.
/// Does not support ENTRYREF.
private static async Task GetAsxMetadata(IFileData playlistFile, IEnumerable files)
{
using var stream = await playlistFile.GetStreamAsync();
var doc = XDocument.Load(stream);
var asx = doc.Root;
var entries = asx.Elements("entry");
var baseUrl = asx.Element("base")?.Value ?? string.Empty;
var playlist = new PlaylistMetadata()
{
Id = playlistFile.Path.HashMD5Fast(),
Title = asx.Element("title")?.Value,
};
foreach (var entry in entries)
{
var entryBaseUrl = entry.Element("base")?.Value ?? string.Empty;
// TODO: Where does the track ID come from?
var path = $"{baseUrl} {entryBaseUrl} {entry.Element("ref")?.Attribute("href")?.Value}";
if (Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out Uri uri))
{
var hash = TryGetHashFromExistingTracks(uri, files);
playlist.TrackIds ??= new HashSet();
if (hash != null)
{
playlist.TrackIds.Add(hash);
}
}
}
return playlist;
}
///
/// Gets tracks from the MPC-PL metatdata in and links them to the given .
///
/// The file to scan for metadata.
/// The scanned metadata files.
private static async Task GetMpcplMetadata(IFileData playlistFile, IEnumerable files)
{
using var stream = await playlistFile.GetStreamAsync();
using var content = new StreamReader(stream);
var metadata = new PlaylistMetadata();
var tracks = new List();
// Make sure the file is either a "pointer" to a folder
// or an MPC playlist
var firstLine = await content.ReadLineAsync();
if (firstLine != "MPCPLAYLIST")
return null;
while (!content.EndOfStream)
{
var trackMetadata = new TrackMetadata();
var line = await content.ReadLineAsync();
var components = Regex.Match(line, @"^(?[0-9]+),(?[A-z]+),(?.+)$").Groups;
switch (components["attr"].Value)
{
case "filename":
var fullPath = ResolveFilePath(components["val"].Value, playlistFile.Path);
trackMetadata.Url = fullPath;
var idx = int.Parse(components["idx"].Value, CultureInfo.InvariantCulture);
if (idx >= tracks.Count)
tracks.Add(trackMetadata);
else
tracks.Insert(idx, trackMetadata);
break;
case "type":
// No idea what this is supposed to mean.
// It's not documented anywhere. Probably supposed to be an enum.
default:
// Unsupported attribute
break;
}
}
return metadata;
}
///
/// Gets tracks from the FPL metatdata in and links them to the given .
///
/// The file to scan for metadata.
/// The scanned metadata files.
///
/// Supports playlists created by foobar2000 v0.9.1 and newer.
/// Based on the specification here: https://github.com/rr-/fpl_reader/blob/master/fpl-format.md
///
private static async Task GetFplMetadata(IFileData playlistFile, IEnumerable files)
{
try
{
// The magic field is a 16-byte magic number.
// More details: https://github.com/rr-/fpl_reader/blob/master/fpl-format.md#magic
byte[] fplMagic = new byte[]
{
0xE1, 0xA0, 0x9C, 0x91, 0xF8, 0x3C, 0x77, 0x42, 0x85, 0x2C, 0x3B, 0xCC, 0x14, 0x01, 0xD3, 0xF2
};
using var stream = await playlistFile.GetStreamAsync();
using var content = new BinaryReader(stream);
var playlist = new PlaylistMetadata()
{
Id = playlistFile.Path.HashMD5Fast()
};
// Make sure the file is an FPL
var fileMagic = content.ReadBytes(fplMagic.Length);
if (!fileMagic.SequenceEqual(fplMagic))
return null;
// foobar2000 playlists don't have titles, so set it
// to the file name
playlist.Title = playlistFile.DisplayName;
// Get size of meta
var metaSize = content.ReadUInt32();
// Read meta strings (null-terminated)
var metaBytes = new byte[metaSize];
var metaPos = stream.Position;
await stream.ReadAsync(metaBytes, 0, metaBytes.Length);
var metas = new List();
if (metas == null) return null;
var metaTemp = string.Empty;
foreach (var b in metaBytes)
{
if (b == 0x00)
{
// End of string
metas.Add(metaTemp);
metaTemp = string.Empty;
}
else
{
// TODO: Is there a better way to do this?
metaTemp += Encoding.UTF8.GetChars(new[] { b })[0];
}
}
// Get track count
var trackCount = content.ReadUInt32();
for (var i = 0; i < trackCount; i++)
{
// Get flags
var flags = content.ReadInt32();
// Get file name offset
var fileNameOffset = content.ReadUInt32();
// Retrieve file name
var curPos = stream.Position;
stream.Seek(metaPos + fileNameOffset, SeekOrigin.Begin);
var pathToTrack = stream.ReadNullTerminatedString(Encoding.UTF8);
stream.Seek(curPos, SeekOrigin.Begin);
playlist.TrackIds ??= new HashSet();
// Get track file size
var fileSize = content.ReadUInt64();
// Get track file time (last modified)
var fileTime = content.ReadUInt64();
// Get track duration
var durationSeconds = content.ReadDouble();
// Get rpg_album, rpg_track, rpk_album, rpk_track
// We don't need it but might as well read it
var rpgAlbum = content.ReadSingle();
var rpgTrack = content.ReadSingle();
var rpkAlbum = content.ReadSingle();
var rpkTrack = content.ReadSingle();
// Get entry count
var entryCount = (int)content.ReadUInt32();
var primaryKeyCount = (int)content.ReadUInt32();
var secondaryKeyCount = (int)content.ReadUInt32();
var secondaryKeysOffset = (int)content.ReadUInt32();
var primaryPairs = new Dictionary(primaryKeyCount);
// Get primary keys
var primaryKeys = new Dictionary(primaryKeyCount);
for (var x = 0; x < primaryKeyCount; x++)
{
var id = content.ReadUInt32();
var nameOffset = content.ReadUInt32();
curPos = stream.Position;
stream.Seek(metaPos + nameOffset, SeekOrigin.Begin);
primaryKeys[id] = stream.ReadNullTerminatedString(Encoding.UTF8);
stream.Seek(curPos, SeekOrigin.Begin);
}
// Read 'unk0', no idea what it does
var unk0 = content.ReadUInt32();
// Get primary pair values
var previousPrimaryKey = primaryKeys.First().Value;
for (uint x = 0; x < primaryKeyCount; x++)
{
if (!string.IsNullOrEmpty(pathToTrack))
{
var valueOffset = content.ReadUInt32();
curPos = stream.Position;
stream.Seek(metaPos + valueOffset, SeekOrigin.Begin);
var value = stream.ReadNullTerminatedString(Encoding.UTF8);
stream.Seek(curPos, SeekOrigin.Begin);
if (Uri.TryCreate(pathToTrack, UriKind.RelativeOrAbsolute, out Uri uri))
{
var hash = TryGetHashFromExistingTracks(uri, files);
if (hash != null)
{
playlist.TrackIds ??= new HashSet();
playlist.TrackIds.Add(hash);
}
}
if (primaryKeys.TryGetValue(x, out string val))
previousPrimaryKey = val;
primaryPairs.Add(previousPrimaryKey, value);
}
}
}
return playlist;
}
catch (Exception)
{
return null;
}
}
///
/// Gets tracks from the PLS metatdata in and links them to the given .
///
private static async Task GetPlsMetadata(IFileData playlistFile, IEnumerable files)
{
using var stream = await playlistFile.GetStreamAsync();
using var content = new StreamReader(stream);
var playlist = new PlaylistMetadata()
{
Id = playlistFile.Path.HashMD5Fast(),
Title = playlistFile.DisplayName
};
// Make sure the file is really a PLS file
var firstLine = await content.ReadLineAsync();
// Not a valid PLS playlist
if (firstLine != "[playlist]")
return null;
var matches = new List();
while (!content.EndOfStream)
{
var line = await content.ReadLineAsync();
var match = Regex.Match(line, @"^(?[A-Za-z]+)(?[0-9]*)=(?.+)$", RegexOptions.Compiled);
if (match.Success)
matches.Add(match);
}
var trackCountMatch = matches.First(m => m.Groups["key"].Value == "NumberOfEntries");
var trackCount = uint.Parse(trackCountMatch.Groups["val"].Value, CultureInfo.InvariantCulture);
matches.Remove(trackCountMatch);
var tracksTable = new Dictionary((int)trackCount);
foreach (var match in matches)
{
var value = match.Groups["val"].Value;
var indexStr = match.Groups["idx"]?.Value;
if (int.TryParse(indexStr, out var index))
{
if (!tracksTable.ContainsKey(index))
tracksTable[index] = new TrackMetadata();
}
switch (match.Groups["key"].Value)
{
case "File":
if (Uri.TryCreate(ResolveFilePath(value, playlistFile), UriKind.RelativeOrAbsolute, out Uri uri))
{
var hash = TryGetHashFromExistingTracks(uri, files);
if (hash != null)
{
playlist.TrackIds ??= new HashSet();
playlist.TrackIds.Add(hash);
}
}
break;
}
}
// Collapse the tracks table to a plain list
var tracks = tracksTable.Select(t => t.Value).PruneNull().ToList();
return playlist;
}
///
/// Gets tracks from the AIMPPL metatdata in and links them to the given .
///
/// Only tested with AIMPPL4 files.
/// The file to scan for metadata.
/// The relevant files to link data to.
private static async Task GetAimpplMetadata(IFileData playlistFile, IEnumerable files)
{
// Adapted from https://github.com/ApexWeed/aimppl-copy/
using var stream = await playlistFile.GetStreamAsync();
using var content = new StreamReader(stream);
var playlist = new PlaylistMetadata()
{
Id = playlistFile.Path.HashMD5Fast(),
};
var mode = AimpplPlaylistMode.Summary;
while (!content.EndOfStream)
{
var line = await content.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line))
continue;
switch (line)
{
case "#-----SUMMARY-----#":
mode = AimpplPlaylistMode.Summary;
continue;
case "#-----SETTINGS-----#":
mode = AimpplPlaylistMode.Settings;
continue;
case "#-----CONTENT-----#":
mode = AimpplPlaylistMode.Content;
continue;
default:
switch (mode)
{
case AimpplPlaylistMode.Summary:
{
var split = line.IndexOf('=');
var variable = line.Substring(0, split);
var value = line.Substring(split + 1);
if (variable == "Name")
playlist.Title = value;
break;
}
case AimpplPlaylistMode.Settings:
break;
case AimpplPlaylistMode.Content:
{
if (string.IsNullOrWhiteSpace(line))
continue;
if (line.StartsWith("-", StringComparison.InvariantCulture))
break;
var trackComponents = line.Split('|');
if (trackComponents.FirstOrDefault() != null)
{
if (Uri.TryCreate(trackComponents[0], UriKind.RelativeOrAbsolute, out Uri uri))
{
var hash = TryGetHashFromExistingTracks(uri, files);
if (hash != null)
{
playlist.TrackIds ??= new HashSet();
playlist.TrackIds.Add(hash);
}
}
}
break;
}
default:
throw new ArgumentOutOfRangeException();
}
break;
}
}
return playlist;
}
}
}