├── .gitignore ├── Config.cs ├── Fuse ├── FuseAttributes.cs ├── FuseException.cs ├── FuseFileSystem.cs ├── FusePlexInterface.cs └── FuseStatusCode.cs ├── Memoriser.cs ├── Plex ├── Api │ ├── BaseApiClient.cs │ ├── FileApiClient.cs │ ├── SectionApiClient.cs │ └── ServerApiClient.cs ├── Auth.cs ├── Filesystem.cs └── Model │ ├── FileType.cs │ ├── FilesystemNode.cs │ ├── Node.cs │ ├── RootNode.cs │ └── ServerNode.cs ├── Program.cs ├── README.md ├── Utils.cs ├── config.example.json ├── global.json └── pfs.csproj /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | yarn-error.log 3 | node_modules 4 | .vscode 5 | test 6 | bin 7 | obj 8 | .vs 9 | .idea 10 | -------------------------------------------------------------------------------- /Config.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.IO; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | 8 | namespace Pfs 9 | { 10 | public class Configuration 11 | { 12 | [DefaultValue("PlexFSv1")] 13 | [JsonPropertyName("cid")] 14 | public string Cid { get; set; } = "PlexFSv1"; 15 | 16 | [DefaultValue("")] 17 | [JsonPropertyName("token")] 18 | public string Token { get; set; } = ""; 19 | 20 | [DefaultValue(true)] 21 | [JsonPropertyName("saveLoginDetails")] 22 | public bool SaveLoginDetails { get; set; } = true; 23 | 24 | [DefaultValue("")] 25 | [JsonPropertyName("mountPath")] 26 | public string MountPath { get; set; } = ""; 27 | 28 | [DefaultValue(-1)] 29 | [JsonPropertyName("uid")] 30 | public long Uid { get; set; } = -1; 31 | 32 | [DefaultValue(-1)] 33 | [JsonPropertyName("gid")] 34 | public long Gid { get; set; } = -1; 35 | 36 | [DefaultValue(3600000)] 37 | [JsonPropertyName("cacheAge")] 38 | public long CacheAge { get; set; } = 3600000; 39 | 40 | [DefaultValue(false)] 41 | [JsonPropertyName("forceMount")] 42 | public bool ForceMount { get; set; } = false; 43 | 44 | [DefaultValue(false)] 45 | [JsonPropertyName("macDisplayMount")] 46 | public bool MacDisplayMount { get; set; } = false; 47 | 48 | [DefaultValue(new[] { "large_read" })] 49 | [JsonPropertyName("fuseOptions")] 50 | public string[] FuseOptions { get; set; } = { "large_read" }; 51 | 52 | [JsonIgnore] private string ConfigurationFile { get; set; } 53 | 54 | private static IDictionary> ReadArgs() 55 | { 56 | var result = new Dictionary>(); 57 | var argv = Environment.GetCommandLineArgs(); 58 | string key = null; 59 | for (var i = 1; i < argv.Length; i++) 60 | { 61 | if (argv[i].StartsWith('-')) 62 | { 63 | var newKey = argv[i].TrimStart('-'); 64 | if (string.IsNullOrWhiteSpace(newKey) || result.ContainsKey(newKey)) 65 | { 66 | throw new Exception($"Duplicate argument '{newKey}' provided"); 67 | } 68 | 69 | result[newKey] = new List(); 70 | key = newKey; 71 | } 72 | else if (string.IsNullOrWhiteSpace(key)) 73 | { 74 | throw new Exception($"Unexpected argument {argv[i]}"); 75 | } 76 | else 77 | { 78 | result[key].Add(argv[i]); 79 | } 80 | } 81 | 82 | return result; 83 | } 84 | 85 | private static bool GetBool(string key, IList input) 86 | { 87 | switch (input.Count) 88 | { 89 | case 0: 90 | return true; 91 | case 1: 92 | try 93 | { 94 | return bool.Parse(input[0]); 95 | } 96 | catch 97 | { 98 | throw new Exception( 99 | $"Argument \"{key}\" expects at most one boolean argument but \"{input[0]}\" was given"); 100 | } 101 | default: 102 | throw new Exception( 103 | $"Argument \"{key}\" expects at most one boolean argument but {input.Count} were given"); 104 | } 105 | } 106 | 107 | private static long GetLong(string key, IList input) 108 | { 109 | if (input.Count != 1) 110 | { 111 | throw new Exception( 112 | $"Argument \"{key}\" expects exactly one numeric argument but {input.Count} were given"); 113 | } 114 | 115 | try 116 | { 117 | return long.Parse(input[0]); 118 | } 119 | catch 120 | { 121 | throw new Exception($"Argument \"{key}\" expects a numeric argument but \"{input[0]}\" was given"); 122 | } 123 | } 124 | 125 | private static string GetString(string key, IList input) 126 | { 127 | if (input.Count != 1) 128 | { 129 | throw new Exception($"Argument \"{key}\" expects exactly one argument but {input.Count} were given"); 130 | } 131 | 132 | return input[0]; 133 | } 134 | 135 | public static Configuration LoadConfig() 136 | { 137 | var cliArgs = ReadArgs(); 138 | var configFile = cliArgs.ContainsKey("configFile") 139 | ? GetString("configFile", cliArgs["configFile"]) 140 | : "config.json"; 141 | Configuration configuration; 142 | if (File.Exists(configFile)) 143 | { 144 | using var file = File.OpenRead(configFile); 145 | configuration = JsonSerializer.Deserialize(file); 146 | } 147 | else 148 | { 149 | configuration = new Configuration(); 150 | } 151 | 152 | configuration.ConfigurationFile = configFile; 153 | 154 | foreach (var key in cliArgs.Keys) 155 | { 156 | switch (key) 157 | { 158 | case "cid": 159 | configuration.Cid = GetString(key, cliArgs[key]); 160 | break; 161 | case "token": 162 | configuration.Token = GetString(key, cliArgs[key]); 163 | break; 164 | case "saveLoginDetails": 165 | configuration.SaveLoginDetails = GetBool(key, cliArgs[key]); 166 | break; 167 | case "mountPath": 168 | configuration.MountPath = GetString(key, cliArgs[key]); 169 | break; 170 | case "uid": 171 | configuration.Uid = GetLong(key, cliArgs[key]); 172 | break; 173 | case "gid": 174 | configuration.Gid = GetLong(key, cliArgs[key]); 175 | break; 176 | case "cacheAge": 177 | configuration.CacheAge = GetLong(key, cliArgs[key]); 178 | break; 179 | case "forceMount": 180 | configuration.ForceMount = GetBool(key, cliArgs[key]); 181 | break; 182 | case "macDisplayMount": 183 | configuration.MacDisplayMount = GetBool(key, cliArgs[key]); 184 | break; 185 | case "fuseOptions": 186 | configuration.FuseOptions = new string[cliArgs[key].Count]; 187 | cliArgs[key].CopyTo(configuration.FuseOptions, 0); 188 | break; 189 | default: 190 | throw new Exception($"Unknown argument {key}"); 191 | } 192 | } 193 | 194 | if (configuration.Uid < 0) 195 | { 196 | configuration.Uid = long.Parse(Environment.GetEnvironmentVariable("UID") ?? "0"); 197 | } 198 | 199 | if (configuration.Gid < 0) 200 | { 201 | configuration.Gid = long.Parse(Environment.GetEnvironmentVariable("GID") ?? "0"); 202 | } 203 | 204 | return configuration; 205 | } 206 | 207 | public static void SaveConfig(Configuration content) 208 | { 209 | using var file = File.OpenWrite(content.ConfigurationFile); 210 | JsonSerializer.Serialize(file, content, new JsonSerializerOptions() 211 | { 212 | WriteIndented = true 213 | }); 214 | } 215 | } 216 | } -------------------------------------------------------------------------------- /Fuse/FuseAttributes.cs: -------------------------------------------------------------------------------- 1 | using Pfs.Plex.Model; 2 | 3 | namespace Pfs.Fuse 4 | { 5 | public class FuseAttributes 6 | { 7 | public const long DIRECTORY_MODE = 16676; 8 | public const long FILE_MODE = 33133; 9 | public long mtime { get; } 10 | public long atime { get; } 11 | public long ctime { get; } 12 | public short nlink { get; } 13 | public long size { get; } 14 | public long mode { get; } 15 | public long uid { get; } 16 | public long gid { get; } 17 | 18 | public FuseAttributes(Node node, Configuration config) 19 | { 20 | mtime = node.LastModified.ToUnixTimestamp(); 21 | atime = node.CreatedAt.ToUnixTimestamp(); 22 | ctime = node.LastModified.ToUnixTimestamp(); 23 | nlink = 1; 24 | size = (node as FileSystemNode)?.Size ?? 4096; 25 | mode = node.Type == FileType.Folder ? DIRECTORY_MODE : FILE_MODE; 26 | uid = config.Uid; 27 | gid = config.Gid; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Fuse/FuseException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Pfs.Fuse 4 | { 5 | public class FuseException : Exception 6 | { 7 | public FuseStatusCode ErrorCode { get; } 8 | public FuseException(FuseStatusCode errorCode) 9 | { 10 | ErrorCode = errorCode; 11 | } 12 | 13 | public override string ToString() 14 | { 15 | return "Error = " + ErrorCode; 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /Fuse/FuseFileSystem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Text.Json; 7 | using Mono.Fuse.NETStandard; 8 | using Mono.Unix.Native; 9 | using pfs; 10 | 11 | namespace Pfs.Fuse 12 | { 13 | public class FuseFileSystem : FileSystem 14 | { 15 | private readonly Configuration _config; 16 | private readonly FusePlexInterface _delegate; 17 | private readonly Memoriser _memoriser; 18 | 19 | public FuseFileSystem(Configuration config) 20 | { 21 | _config = config; 22 | _delegate = new FusePlexInterface(config); 23 | _memoriser = new Memoriser(config); 24 | } 25 | 26 | protected override Errno OnReleaseHandle(string file, OpenedPathInfo info) 27 | { 28 | try 29 | { 30 | return (Errno)_memoriser.Memorise(_delegate.release, file, info.Handle.ToInt64()); 31 | } 32 | catch (Exception e) 33 | { 34 | return HandleException(e); 35 | } 36 | } 37 | 38 | protected override Errno OnReleaseDirectory(string directory, OpenedPathInfo info) 39 | { 40 | try 41 | { 42 | return (Errno)_memoriser.Memorise(_delegate.releasedir, directory, info.Handle.ToInt64()); 43 | } 44 | catch (Exception e) 45 | { 46 | return HandleException(e); 47 | } 48 | } 49 | 50 | protected override Errno OnOpenHandle(string file, OpenedPathInfo info) 51 | { 52 | try 53 | { 54 | return (Errno)_memoriser.Memorise(_delegate.open, file, (long)info.OpenFlags).Result; 55 | } 56 | catch (Exception e) 57 | { 58 | return HandleException(e); 59 | } 60 | } 61 | 62 | protected override Errno OnOpenDirectory(string directory, OpenedPathInfo info) 63 | { 64 | try 65 | { 66 | return (Errno)_memoriser.Memorise(_delegate.opendir, directory, (long)info.OpenFlags).Result; 67 | } 68 | catch (Exception e) 69 | { 70 | return HandleException(e); 71 | } 72 | } 73 | 74 | protected override Errno OnGetPathStatus(string path, out Stat stat) 75 | { 76 | return OnGetHandleStatus(path, null, out stat); 77 | } 78 | 79 | protected override Errno OnGetHandleStatus(string file, OpenedPathInfo info, out Stat buf) 80 | { 81 | try 82 | { 83 | var result = _memoriser.Memorise(_delegate.getattr, file).Result; 84 | buf = new Stat() 85 | { 86 | st_atime = result.atime, 87 | st_ctime = result.ctime, 88 | st_mtime = result.mtime, 89 | st_nlink = (ulong)result.nlink, 90 | st_mode = (FilePermissions)result.mode, 91 | st_size = result.size, 92 | st_gid = (uint)result.gid, 93 | st_uid = (uint)result.uid 94 | }; 95 | return (Errno)FuseStatusCode.Success; 96 | } 97 | catch (Exception e) 98 | { 99 | buf = new Stat(); 100 | return HandleException(e); 101 | } 102 | } 103 | 104 | protected override Errno OnReadDirectory(string directory, OpenedPathInfo info, 105 | out IEnumerable paths) 106 | { 107 | try 108 | { 109 | var results = _memoriser.Memorise(_delegate.readdir, directory).Result; 110 | paths = results.Select(name => { return new DirectoryEntry(name); }); 111 | return (Errno)FuseStatusCode.Success; 112 | } 113 | catch (Exception e) 114 | { 115 | paths = new List(); 116 | return HandleException(e); 117 | } 118 | } 119 | 120 | protected override Errno OnReadHandle(string file, OpenedPathInfo info, byte[] buf, long offset, 121 | out int bytesWritten) 122 | { 123 | try 124 | { 125 | bytesWritten = (int)_delegate.read(file, buf, offset).Result; 126 | return (Errno)FuseStatusCode.Success; 127 | } 128 | catch (Exception e) 129 | { 130 | bytesWritten = 0; 131 | return HandleException(e); 132 | } 133 | } 134 | 135 | private Errno HandleException(Exception e) 136 | { 137 | FuseException exception = null; 138 | if (e is FuseException fuseExceptionFirst) 139 | { 140 | exception = fuseExceptionFirst; 141 | } 142 | 143 | if (e is AggregateException aggregateException && 144 | aggregateException.GetBaseException() is FuseException fuseExceptionSecond) 145 | { 146 | exception = fuseExceptionSecond; 147 | } 148 | 149 | Debug.WriteLine(exception.Message); 150 | Debug.WriteLine(exception.StackTrace); 151 | if (exception != null) 152 | { 153 | return (Errno)exception.ErrorCode; 154 | } 155 | return Errno.ENOENT; 156 | } 157 | 158 | public void Mount() 159 | { 160 | try 161 | { 162 | if (string.IsNullOrWhiteSpace(_config.MountPath)) 163 | { 164 | throw new Exception("mountPath must be specified"); 165 | } 166 | 167 | MultiThreaded = true; 168 | MountPoint = _config.MountPath; 169 | ParseFuseArguments(_config.FuseOptions); 170 | Start(); 171 | } 172 | catch (Exception e) 173 | { 174 | Console.WriteLine($"Mounting failed due to an error: {e.Message}"); 175 | throw; 176 | } 177 | } 178 | 179 | public void UnMount() 180 | { 181 | try 182 | { 183 | Stop(); 184 | } 185 | catch (Exception e) 186 | { 187 | Console.WriteLine($"Unmounting failed due to an error: {e.Message}"); 188 | throw; 189 | } 190 | } 191 | 192 | public string TestInput(string path) 193 | { 194 | var err = OnGetHandleStatus(path, null, out var buf); 195 | if (err != 0) 196 | { 197 | return "Error = " + err; 198 | } 199 | 200 | var result = "Stat = " + 201 | Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(buf, 202 | new JsonSerializerOptions { WriteIndented = true })) + "\n"; 203 | 204 | var isFile = (long)buf.st_mode == FuseAttributes.FILE_MODE; 205 | if (isFile) 206 | { 207 | result += "Type = File\n"; 208 | } 209 | else 210 | { 211 | OnReadDirectory(path, null, out var paths); 212 | result += "Type = Folder\n"; 213 | result += "Files = [" + string.Join(", ", paths.Select(p => p.Name)) + "]\n"; 214 | } 215 | 216 | return result; 217 | } 218 | } 219 | } -------------------------------------------------------------------------------- /Fuse/FusePlexInterface.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using Pfs.Plex; 4 | using Pfs.Plex.Model; 5 | using System; 6 | namespace Pfs.Fuse 7 | { 8 | public class FusePlexInterface : FileSystem 9 | { 10 | private readonly Configuration _config; 11 | 12 | public FusePlexInterface(Configuration config) : base(config) 13 | { 14 | _config = config; 15 | } 16 | 17 | public FuseStatusCode release(string path, long fd) 18 | { 19 | return FuseStatusCode.Success; 20 | } 21 | 22 | public FuseStatusCode releasedir(string path, long fd) 23 | { 24 | return release(path, fd); 25 | } 26 | 27 | public async Task getattr(string path) 28 | { 29 | var file = await GetFile(Utils.NormalisePath(path)); 30 | if (file == null) 31 | { 32 | throw new FuseException(FuseStatusCode.ENOENT); 33 | } 34 | return new FuseAttributes(file, _config); 35 | } 36 | 37 | private async Task _BaseOpen(string path, long flags, FileType type) 38 | { 39 | path = Utils.NormalisePath(path); 40 | if ((flags & 3) != 0) 41 | { 42 | // this fs is read only, reject 43 | throw new FuseException(FuseStatusCode.EACCES); 44 | } 45 | var file = await GetFile(path); 46 | if (file == null) 47 | { 48 | throw new FuseException(FuseStatusCode.ENOENT); 49 | } 50 | switch (type) 51 | { 52 | case FileType.Folder when file.Type == FileType.File: 53 | throw new FuseException(FuseStatusCode.ENOTDIR); 54 | case FileType.File when file.Type == FileType.Folder: 55 | throw new FuseException(FuseStatusCode.EISDIR); 56 | default: 57 | return (long) FuseStatusCode.Success; 58 | } 59 | } 60 | 61 | public async Task open(string path, long flags) 62 | { 63 | return await _BaseOpen(path, flags, FileType.File); 64 | } 65 | 66 | public async Task opendir(string path, long flags) 67 | { 68 | return await _BaseOpen(path, flags, FileType.Folder); 69 | } 70 | 71 | public async Task readdir(string path) 72 | { 73 | var files = await ListFiles(Utils.NormalisePath(path)); 74 | return files.Select(f => f.Name).ToArray(); 75 | } 76 | 77 | public async Task read(string path, byte[] buffer, long position) 78 | { 79 | try 80 | { 81 | var p = Utils.NormalisePath(path); 82 | return await this.OpenFile(p, position, buffer); 83 | } 84 | catch (InvalidOperationException e) 85 | { 86 | if (e.Message == "No such file") 87 | { 88 | throw new FuseException(FuseStatusCode.ENOENT); 89 | } 90 | throw e; 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Fuse/FuseStatusCode.cs: -------------------------------------------------------------------------------- 1 | using Mono.Unix.Native; 2 | 3 | namespace Pfs.Fuse 4 | { 5 | public enum FuseStatusCode 6 | { 7 | Success = 0, 8 | ENOENT = Errno.ENOENT, 9 | ENOTDIR = Errno.ENOTDIR, 10 | EISDIR = Errno.EISDIR, 11 | EACCES = Errno.EACCES 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Memoriser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.ExceptionServices; 5 | using System.Threading.Tasks; 6 | using Pfs; 7 | 8 | namespace pfs 9 | { 10 | public class Memoriser 11 | { 12 | private readonly TimeSpan _maxCacheAge; 13 | private readonly IDictionary _resultCache; 14 | public Memoriser(Configuration config) 15 | { 16 | _maxCacheAge = TimeSpan.FromMilliseconds(config.CacheAge); 17 | _resultCache = new Dictionary(); 18 | } 19 | 20 | private static long GenerateHashCode(params object[] input) 21 | { 22 | return input.Aggregate(27, (current, i) => 13 * current + i.GetHashCode()); 23 | } 24 | 25 | private T GetCachedResult(long key) 26 | { 27 | if (!this._resultCache.ContainsKey(key)) 28 | { 29 | return default(T); 30 | } 31 | 32 | var cached = _resultCache[key]; 33 | if (DateTimeOffset.Now - cached.AddedAt >= _maxCacheAge) 34 | { 35 | _resultCache.Remove(key); 36 | return default(T); 37 | } 38 | 39 | if (cached.Value is Exception exception) 40 | { 41 | ExceptionDispatchInfo.Capture(exception).Throw(); 42 | } 43 | 44 | return (T) cached.Value; 45 | } 46 | 47 | public Q Memorise(Func callback, T arg) 48 | { 49 | var hash = GenerateHashCode(callback, arg); 50 | var cacheResult = GetCachedResult(hash); 51 | if (!object.Equals(cacheResult, default(Q))) 52 | { 53 | return cacheResult; 54 | } 55 | try 56 | { 57 | var result = callback(arg); 58 | _resultCache[hash] = new CacheItem(result); 59 | return result; 60 | } 61 | catch (Exception e) 62 | { 63 | _resultCache[hash] = new CacheItem(e); 64 | throw; 65 | } 66 | } 67 | 68 | public async Task Memorise(Func> callback, T arg) 69 | { 70 | var hash = GenerateHashCode(callback, arg); 71 | var cacheResult = GetCachedResult(hash); 72 | if (!object.Equals(cacheResult, default(Q))) 73 | { 74 | return cacheResult; 75 | } 76 | try 77 | { 78 | var result = await callback(arg); 79 | _resultCache[hash] = new CacheItem(result); 80 | return result; 81 | } 82 | catch (Exception e) 83 | { 84 | _resultCache[hash] = new CacheItem(e); 85 | throw; 86 | } 87 | } 88 | 89 | public Q Memorise(Func callback, P arg1, T arg2) 90 | { 91 | var hash = GenerateHashCode(callback, arg1, arg2); 92 | var cacheResult = GetCachedResult(hash); 93 | if (!object.Equals(cacheResult, default(Q))) 94 | { 95 | return cacheResult; 96 | } 97 | try 98 | { 99 | var result = callback(arg1, arg2); 100 | _resultCache[hash] = new CacheItem(result); 101 | return result; 102 | } 103 | catch (Exception e) 104 | { 105 | _resultCache[hash] = new CacheItem(e); 106 | throw; 107 | } 108 | } 109 | 110 | public async Task Memorise(Func> callback, P arg1, T arg2) 111 | { 112 | var hash = GenerateHashCode(callback, arg1, arg2); 113 | var cacheResult = GetCachedResult(hash); 114 | if (!object.Equals(cacheResult, default(Q))) 115 | { 116 | return cacheResult; 117 | } 118 | try 119 | { 120 | var result = await callback(arg1, arg2); 121 | _resultCache[hash] = new CacheItem(result); 122 | return result; 123 | } 124 | catch (Exception e) 125 | { 126 | _resultCache[hash] = new CacheItem(e); 127 | throw; 128 | } 129 | } 130 | 131 | public async Task Memorise(Func> callback, P arg1, T arg2, R arg3) 132 | { 133 | var hash = GenerateHashCode(callback, arg1, arg2, arg3); 134 | var cacheResult = GetCachedResult(hash); 135 | if (!object.Equals(cacheResult, default(Q))) 136 | { 137 | return cacheResult; 138 | } 139 | try 140 | { 141 | var result = await callback(arg1, arg2, arg3); 142 | _resultCache[hash] = new CacheItem(result); 143 | return result; 144 | } 145 | catch (Exception e) 146 | { 147 | _resultCache[hash] = new CacheItem(e); 148 | throw; 149 | } 150 | } 151 | 152 | private class CacheItem 153 | { 154 | public CacheItem(object value) 155 | { 156 | Value = value; 157 | AddedAt = DateTime.Now; 158 | } 159 | public object Value { get; } 160 | 161 | public DateTime AddedAt { get; } 162 | } 163 | } 164 | } -------------------------------------------------------------------------------- /Plex/Api/BaseApiClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Net; 6 | using System.Net.Http; 7 | using System.Net.Http.Headers; 8 | using System.Text.Json; 9 | using System.Threading.Tasks; 10 | using System.Xml.Serialization; 11 | using pfs; 12 | 13 | namespace Pfs.Plex.Api 14 | { 15 | public class BaseApiClient : IDisposable 16 | { 17 | private readonly string _token; 18 | private readonly string _cid; 19 | private readonly HttpClient _client; 20 | private readonly Memoriser _memoriser; 21 | 22 | public BaseApiClient(Configuration config) 23 | { 24 | _token = config.Token; 25 | _cid = config.Cid; 26 | _client = new HttpClient(); 27 | _client.DefaultRequestHeaders.Accept.Clear(); 28 | _client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 29 | _memoriser = new Memoriser(config); 30 | ServicePointManager.DefaultConnectionLimit = 50; 31 | } 32 | 33 | private string _BuildPlexUrl(string server, string path, IDictionary urlParams) 34 | { 35 | if (!urlParams.ContainsKey("X-Plex-Token")) 36 | { 37 | urlParams["X-Plex-Token"] = _token; 38 | } 39 | if (!urlParams.ContainsKey("X-Plex-Client-Identifier")) 40 | { 41 | urlParams["X-Plex-Client-Identifier"] = _cid; 42 | } 43 | 44 | return server + path + "?" + urlParams 45 | .Select(p => p.Key + "=" + p.Value) 46 | .Aggregate("", (current, next) => current.Length == 0 ? next : (current + "&" + next)); 47 | } 48 | 49 | public async Task JsonFetch(string server, string path, IDictionary urlParams) 50 | { 51 | return await _memoriser.Memorise(_InternalJsonFetch, server, path, urlParams); 52 | } 53 | 54 | private async Task _InternalJsonFetch(string server, string path, IDictionary urlParams) 55 | { 56 | var response = await _client.GetAsync(_BuildPlexUrl(server, path, urlParams)); 57 | response.EnsureSuccessStatusCode(); 58 | 59 | return await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync()); 60 | } 61 | 62 | private T _ToXml(Stream inputXmlStream) 63 | { 64 | var serializer = new XmlSerializer(typeof(T)); 65 | return (T) serializer.Deserialize(inputXmlStream); 66 | } 67 | 68 | public async Task XmlFetch(string server, string path, IDictionary urlParams) 69 | { 70 | return await _memoriser.Memorise(_InternalXmlFetch, server, path, urlParams); 71 | } 72 | 73 | private async Task _InternalXmlFetch(string server, string path, IDictionary urlParams) 74 | { 75 | var response = await _client.GetAsync(_BuildPlexUrl(server, path, urlParams)); 76 | response.EnsureSuccessStatusCode(); 77 | return _ToXml(await response.Content.ReadAsStreamAsync()); 78 | } 79 | 80 | public async Task HeadFetch(string uri) 81 | { 82 | return await _memoriser.Memorise(_InternalHeadFetch, uri); 83 | } 84 | 85 | private async Task _InternalHeadFetch(string uri) 86 | { 87 | using (var headClient = new HttpClient()) 88 | { 89 | headClient.Timeout = TimeSpan.FromMilliseconds(500); 90 | try 91 | { 92 | await _client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); 93 | } 94 | catch(TaskCanceledException) 95 | { 96 | throw new Exception(); 97 | } 98 | } 99 | return uri; 100 | } 101 | 102 | public async Task BufferFetch(string server, string path, IDictionary urlParams, long startIndex, long endIndex, byte[] outputBuffer) 103 | { 104 | using (var request = new HttpRequestMessage(HttpMethod.Get, _BuildPlexUrl(server, path, urlParams))) 105 | { 106 | request.Headers.Add("Range", $"bytes={startIndex}-{endIndex}"); 107 | using (var response = await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)) 108 | { 109 | response.EnsureSuccessStatusCode(); 110 | using (var sourceStream = await response.Content.ReadAsStreamAsync()) 111 | { 112 | var maxLength = (int) Math.Min(response.Content.Headers.ContentLength ?? long.MaxValue, outputBuffer.Length); 113 | var read = 0; 114 | var lastNumberOfBytes = 0; 115 | do 116 | { 117 | lastNumberOfBytes = await sourceStream.ReadAsync(outputBuffer, read, maxLength - read); 118 | read += lastNumberOfBytes; 119 | } 120 | while (lastNumberOfBytes > 0 && read < maxLength); 121 | return Math.Max(read, 0); 122 | } 123 | } 124 | } 125 | } 126 | 127 | public void Dispose() 128 | { 129 | _client.Dispose(); 130 | } 131 | } 132 | } 133 | 134 | -------------------------------------------------------------------------------- /Plex/Api/FileApiClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Pfs.Plex.Model; 5 | 6 | namespace Pfs.Plex.Api 7 | { 8 | public class FileClient 9 | { 10 | private BaseApiClient _client; 11 | public FileClient(BaseApiClient client) 12 | { 13 | this._client = client; 14 | } 15 | 16 | public async Task GetFileBuffer(FileSystemNode file, long startIndex, byte[] outputBuffer) 17 | { 18 | if (startIndex >= file.Size) 19 | { 20 | // nothing to do, we have read 0 bytes 21 | return 0; 22 | } 23 | 24 | var endIndex = Math.Min(startIndex + outputBuffer.Length - 1, file.Size - 1); 25 | 26 | return await this._client.BufferFetch(file.Server.Url, file.Next, new Dictionary{ 27 | { "X-Plex-Token", file.Server.Token }, 28 | { "download", "1" } 29 | }, startIndex, endIndex, outputBuffer); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Plex/Api/SectionApiClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Text.Json.Serialization; 7 | using System.Threading.Tasks; 8 | using Pfs.Plex.Model; 9 | 10 | namespace Pfs.Plex.Api 11 | { 12 | public class SectionsClient 13 | { 14 | private readonly BaseApiClient _client; 15 | 16 | public SectionsClient(BaseApiClient client) 17 | { 18 | _client = client; 19 | } 20 | 21 | public async Task> ListSections(ServerNode server) 22 | { 23 | var sections = await _client.JsonFetch(server.Url, "/library/sections", new Dictionary 24 | { 25 | { "X-Plex-Token", server.Token } 26 | }); 27 | 28 | if (sections?.MediaContainer?.DirectoryItems == null) 29 | { 30 | if (sections?.MediaContainer?.Size != 0) 31 | { 32 | Debug.WriteLine($"User should have access to {sections?.MediaContainer?.Size} sections, but none were returned."); 33 | } 34 | return new List(); 35 | } 36 | 37 | var results = sections.MediaContainer.DirectoryItems.Select(d => new FileSystemNode( 38 | d.RatingKey, 39 | d.Title, 40 | d.AddedAt, 41 | d.UpdatedAt, 42 | FileType.Folder, 43 | server, 44 | $"/library/sections/{d.Key}/all" 45 | )).ToList(); 46 | Utils.CleanAndDedupe(results); 47 | return results; 48 | } 49 | 50 | public async Task> ListSectionItems(FileSystemNode section) 51 | { 52 | if (section.Server?.Url == null || section.Next == null) 53 | { 54 | throw new Exception("No such file or folder"); 55 | } 56 | 57 | var sectionItems = await _client.JsonFetch(section.Server.Url, section.Next, new Dictionary 58 | { 59 | { "X-Plex-Token", section.Server.Token } 60 | }); 61 | 62 | if (sectionItems.MediaContainer?.MetadataItems == null) 63 | { 64 | if (sectionItems.MediaContainer?.Size != 0) 65 | { 66 | Debug.WriteLine($"No items were returned from plex when there should have been {sectionItems.MediaContainer?.Size} items."); 67 | } 68 | return new List(); 69 | } 70 | 71 | var res = sectionItems.MediaContainer.MetadataItems.SelectMany(d => 72 | { 73 | if (d.Media != null) 74 | { 75 | return d.Media.SelectMany(m => m.Parts).Select(p => new FileSystemNode( 76 | p.Id.ToString(), 77 | Path.GetFileName(Utils.NormalisePath(p.File)), 78 | d.AddedAt, 79 | d.UpdatedAt, 80 | FileType.File, 81 | section.Server, 82 | p.Key, 83 | p.Size 84 | )); 85 | } 86 | else 87 | { 88 | return new[] { 89 | new FileSystemNode( 90 | d.RatingKey, 91 | d.Title, 92 | d.AddedAt, 93 | d.UpdatedAt, 94 | FileType.Folder, 95 | section.Server, 96 | d.Key 97 | ) 98 | }; 99 | } 100 | }).ToList(); 101 | 102 | Utils.CleanAndDedupe(res); 103 | return res; 104 | } 105 | 106 | private class SectionJsonModel 107 | { 108 | public MediaContainer MediaContainer { get; set; } 109 | } 110 | 111 | private class MediaContainer 112 | { 113 | [JsonPropertyName("size")] 114 | public int Size { get; set; } 115 | [JsonPropertyName("Metadata")] 116 | public List MetadataItems { get; set; } 117 | [JsonPropertyName("Directory")] 118 | public List DirectoryItems { get; set; } 119 | } 120 | 121 | private class Metadata 122 | { 123 | [JsonPropertyName("ratingKey")] 124 | public string RatingKey { get; set; } 125 | [JsonPropertyName("key")] 126 | public string Key { get; set; } 127 | [JsonPropertyName("title")] 128 | public string Title { get; set; } 129 | [JsonPropertyName("addedAt")] 130 | [JsonConverter(typeof(Utils.PlexDateTimeConverter))] 131 | public DateTime AddedAt { get; set; } 132 | [JsonPropertyName("createdAt")] 133 | [JsonConverter(typeof(Utils.PlexDateTimeConverter))] 134 | private DateTime CreatedAt 135 | { 136 | set => AddedAt = value; 137 | } 138 | [JsonPropertyName("updatedAt")] 139 | [JsonConverter(typeof(Utils.PlexDateTimeConverter))] 140 | public DateTime UpdatedAt { get; set; } 141 | public List Media { get; set; } 142 | } 143 | 144 | private class Medium 145 | { 146 | [JsonPropertyName("Part")] 147 | public List Parts { get; set; } 148 | } 149 | 150 | private class Part 151 | { 152 | [JsonPropertyName("id")] 153 | public int Id { get; set; } 154 | [JsonPropertyName("key")] 155 | public string Key { get; set; } 156 | [JsonPropertyName("file")] 157 | public string File { get; set; } 158 | [JsonPropertyName("size")] 159 | public long Size { get; set; } 160 | } 161 | } 162 | } 163 | 164 | -------------------------------------------------------------------------------- /Plex/Api/ServerApiClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using System.Xml.Serialization; 7 | using Pfs.Plex.Model; 8 | 9 | namespace Pfs.Plex.Api 10 | { 11 | public class ServersClient 12 | { 13 | private readonly BaseApiClient _client; 14 | private readonly Random _random; 15 | 16 | public ServersClient(BaseApiClient client) 17 | { 18 | _client = client; 19 | _random = new Random(); 20 | } 21 | 22 | private async Task _FindServer(IEnumerable uris) 23 | { 24 | var tasks = uris.Select(uri => _client.HeadFetch(uri)).ToList(); 25 | return await Utils.FirstSuccessfulTask(tasks); 26 | } 27 | 28 | private async Task ToServer(Device device) 29 | { 30 | var url = await _FindServer(device.Connection.Select(c => c.Uri)); 31 | return url == null ? null : new ServerNode( 32 | _random.Next().ToString(), 33 | device.Name, 34 | device.CreatedAt.ToDateTime(), 35 | device.LastSeenAt.ToDateTime(), 36 | device.AccessToken, 37 | url 38 | ); 39 | } 40 | 41 | public async Task> ListServers() 42 | { 43 | var servers = await _client.XmlFetch("https://plex.tv", "/api/resources", new Dictionary 44 | { 45 | { "includeHttps", "1" }, 46 | { "includeRelay", "1" } 47 | }); 48 | 49 | var serverCount = servers.Device?.Count ?? 0; 50 | if (serverCount == 0) 51 | { 52 | Console.WriteLine("You do not have access to any plex servers!"); 53 | return new List(); 54 | } 55 | 56 | if ((servers.Device == null || int.Parse(servers.Size) != servers.Device.Count)) 57 | { 58 | Debug.WriteLine($"User should have access to {servers.Size} servers, but none were returned."); 59 | } 60 | 61 | var filtered = (await Task.WhenAll((servers.Device ?? throw new InvalidOperationException()) 62 | .Where(d => d.Presence == "1") 63 | .Select(ToServer))) 64 | .Where(d => !string.IsNullOrWhiteSpace(d?.Url)) 65 | .ToList(); 66 | 67 | Utils.CleanAndDedupe(filtered); 68 | 69 | return filtered; 70 | } 71 | 72 | [XmlRoot(ElementName="MediaContainer")] 73 | public class MediaContainer 74 | { 75 | [XmlElement(ElementName="Device")] 76 | public List Device { get; set; } 77 | [XmlAttribute(AttributeName="size")] 78 | public string Size { get; set; } 79 | } 80 | 81 | [XmlRoot(ElementName="Device")] 82 | public class Device 83 | { 84 | [XmlElement(ElementName="Connection")] 85 | public List Connection { get; set; } 86 | [XmlAttribute(AttributeName="name")] 87 | public string Name { get; set; } 88 | [XmlAttribute(AttributeName="createdAt")] 89 | public string CreatedAt { get; set; } 90 | [XmlAttribute(AttributeName="lastSeenAt")] 91 | public string LastSeenAt { get; set; } 92 | [XmlAttribute(AttributeName="accessToken")] 93 | public string AccessToken { get; set; } 94 | [XmlAttribute(AttributeName="presence")] 95 | public string Presence { get; set; } 96 | } 97 | 98 | [XmlRoot(ElementName="Connection")] 99 | public class Connection 100 | { 101 | [XmlAttribute(AttributeName="uri")] 102 | public string Uri { get; set; } 103 | } 104 | } 105 | } 106 | 107 | -------------------------------------------------------------------------------- /Plex/Auth.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Net.Http; 5 | using System.Net.Http.Headers; 6 | using System.Text.Json; 7 | using System.Text.Json.Serialization; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | namespace Pfs.Plex 12 | { 13 | public static class PlexOAuth 14 | { 15 | private static readonly IDictionary PlexHeaders = new Dictionary 16 | { 17 | { "X-Plex-Product", "PlexFS" }, 18 | { "X-Plex-Version", "PlexFS" }, 19 | { "X-Plex-Client-Identifier", "PlexFSv1" } 20 | }; 21 | 22 | private class OAuthPin 23 | { 24 | [JsonPropertyName("id")] 25 | public long Id { get; set; } 26 | [JsonPropertyName("code")] 27 | public string Code { get; set; } 28 | } 29 | 30 | private static async Task _GetPlexOAuthPin() 31 | { 32 | using (var client = new HttpClient()) 33 | { 34 | client.DefaultRequestHeaders.Accept.Clear(); 35 | client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 36 | foreach (var val in PlexHeaders) 37 | { 38 | client.DefaultRequestHeaders.Add(val.Key, val.Value); 39 | } 40 | 41 | var response = await client.PostAsync("https://plex.tv/api/v2/pins?strong=true", 42 | new ByteArrayContent(new byte[0])); 43 | return JsonSerializer.Deserialize( 44 | await response.Content.ReadAsStreamAsync()); 45 | } 46 | } 47 | 48 | private class OAuthTokenResponse 49 | { 50 | [JsonPropertyName("authToken")] 51 | public string AuthToken { get; set; } 52 | } 53 | 54 | private static async Task _PerformLogin() 55 | { 56 | var oAuthPin = await _GetPlexOAuthPin(); 57 | var url = $"https://app.plex.tv/auth/#!?clientID={PlexHeaders["X-Plex-Client-Identifier"]}&code={oAuthPin.Code}"; 58 | try 59 | { 60 | Process.Start(url); 61 | } 62 | catch (Exception e) 63 | { 64 | Debug.WriteLine(e); 65 | } 66 | 67 | Console.WriteLine( 68 | $"Please authenticate in your web browser. If your web browser did not open, please go to {url}"); 69 | 70 | string token; 71 | using var client = new HttpClient(); 72 | client.DefaultRequestHeaders.Accept.Clear(); 73 | client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); 74 | foreach (var val in PlexHeaders) 75 | { 76 | client.DefaultRequestHeaders.Add(val.Key, val.Value); 77 | } 78 | 79 | var pinUrl = $"https://plex.tv/api/v2/pins/{oAuthPin.Id}"; 80 | while (true) 81 | { 82 | try 83 | { 84 | var response = await client.GetAsync(pinUrl); 85 | var oAuthTokenResponse = JsonSerializer.Deserialize( 86 | await response.Content.ReadAsStreamAsync()); 87 | if (!string.IsNullOrWhiteSpace(oAuthTokenResponse.AuthToken)) 88 | { 89 | token = oAuthTokenResponse.AuthToken; 90 | break; 91 | } 92 | } 93 | catch(Exception e) 94 | { 95 | Debug.WriteLine(e); 96 | // user has not authed yet 97 | } 98 | 99 | Console.WriteLine("Sleeping for 5 seconds before checking again..."); 100 | Thread.Sleep(5000); 101 | } 102 | 103 | return token; 104 | } 105 | 106 | public static async Task<(string, string)> GetLoginDetails() 107 | { 108 | Console.WriteLine("Login Required."); 109 | var token = await _PerformLogin(); 110 | Console.WriteLine("Login Complete. Plex token received."); 111 | return (PlexHeaders["X-Plex-Client-Identifier"], token); 112 | } 113 | } 114 | } -------------------------------------------------------------------------------- /Plex/Filesystem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using pfs; 7 | using Pfs.Plex.Api; 8 | using Pfs.Plex.Model; 9 | 10 | namespace Pfs.Plex 11 | { 12 | public class FileSystem 13 | { 14 | private readonly char[] _separators = {Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar}; 15 | 16 | private readonly ServersClient _servers; 17 | private readonly SectionsClient _sections; 18 | private readonly FileClient _file; 19 | private readonly Memoriser _memoriser; 20 | 21 | protected FileSystem(Configuration config) 22 | { 23 | var client = new BaseApiClient(config); 24 | this._servers = new ServersClient(client); 25 | this._sections = new SectionsClient(client); 26 | this._file = new FileClient(client); 27 | this._memoriser = new Memoriser(config); 28 | } 29 | 30 | public async Task> ListFiles(string inputPath) 31 | { 32 | return await this._memoriser.Memorise(this._InternalListFiles, inputPath); 33 | } 34 | 35 | private async Task> _InternalListFiles(string inputPath) 36 | { 37 | // todo: this code assumes that names of path components are unique, which they may not be 38 | 39 | var spl = inputPath.Split(_separators) 40 | .Skip(1) 41 | .Where(s => !string.IsNullOrWhiteSpace(s.Trim())) 42 | .ToList(); 43 | 44 | var servers = await this._servers.ListServers(); 45 | if (spl.Count == 0) 46 | { 47 | return servers; 48 | } 49 | var server = servers.FirstOrDefault(s => s.Name == spl[0]); 50 | 51 | var rootSections = await this._sections.ListSections(server); 52 | if (spl.Count == 1) 53 | { 54 | return rootSections; 55 | } 56 | var rootSection = rootSections.FirstOrDefault(s => s.Name == spl[1]); 57 | 58 | var lastSection = rootSection; 59 | IEnumerable sections = null; 60 | for (var i = 2; i <= spl.Count; i++) 61 | { 62 | sections = await this._sections.ListSectionItems(lastSection); 63 | if (i < spl.Count) 64 | { 65 | lastSection = sections.FirstOrDefault(s => s.Name == spl[i]); 66 | } 67 | } 68 | return sections; 69 | } 70 | 71 | public async Task GetFile(string inputPath) 72 | { 73 | return await this._memoriser.Memorise(this._InternalGetFile, inputPath); 74 | } 75 | 76 | private async Task _InternalGetFile(string inputPath) 77 | { 78 | if (inputPath.Length == 1 && _separators.Contains(inputPath[0])) 79 | { 80 | return new RootNode(); 81 | } 82 | 83 | var (folder, file) = Utils.GetPathInfo(inputPath); 84 | var files = await this.ListFiles(folder); 85 | return files.FirstOrDefault(f => f.Name == file); 86 | } 87 | 88 | public async Task OpenFile(string inputPath, long startIndex, byte[] outputBuffer) 89 | { 90 | var file = (await this.GetFile(inputPath)) as FileSystemNode; 91 | if (file == null) 92 | { 93 | throw new InvalidOperationException("No such file"); 94 | } 95 | 96 | return await this._file.GetFileBuffer(file, startIndex, outputBuffer); 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /Plex/Model/FileType.cs: -------------------------------------------------------------------------------- 1 | namespace Pfs.Plex.Model 2 | { 3 | public enum FileType 4 | { 5 | File, 6 | Folder 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Plex/Model/FilesystemNode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Pfs.Plex.Model 4 | { 5 | public class FileSystemNode : Node 6 | { 7 | public ServerNode Server { get; } 8 | public string Next { get; } 9 | public long Size { get; } 10 | 11 | public FileSystemNode(string id, string name, DateTime createdAt, DateTime lastModified, FileType fileType, ServerNode server, string next, long size = 4096) 12 | : base(id, name, createdAt, lastModified, fileType) 13 | { 14 | Server = server; 15 | Next = next; 16 | Size = size; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Plex/Model/Node.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Pfs.Plex.Model 4 | { 5 | public abstract class Node 6 | { 7 | public string Id { get; } 8 | public string Name { get; set; } 9 | public DateTime CreatedAt { get; } 10 | public DateTime LastModified { get; } 11 | public FileType Type { get; } 12 | 13 | protected Node(string id, string name, DateTime createdAt, DateTime lastModified, FileType type) 14 | { 15 | Id = id; 16 | Name = name; 17 | CreatedAt = createdAt; 18 | LastModified = lastModified; 19 | Type = type; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Plex/Model/RootNode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Pfs.Plex.Model 4 | { 5 | public class RootNode : Node 6 | { 7 | public RootNode() : base("0", "Plex", DateTime.Now, DateTime.Now, FileType.Folder) 8 | { 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Plex/Model/ServerNode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Pfs.Plex.Model 4 | { 5 | public class ServerNode : Node 6 | { 7 | public string Token { get; } 8 | public string Url { get; } 9 | 10 | public ServerNode(string id, string name, DateTime createdAt, DateTime lastModified, string token, string url) 11 | : base(id, name, createdAt, lastModified, FileType.Folder) 12 | { 13 | Token = token; 14 | Url = url; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Threading.Tasks; 4 | using Pfs.Fuse; 5 | using Pfs.Plex; 6 | 7 | namespace Pfs 8 | { 9 | public static class Program 10 | { 11 | private static bool _terminateReceived; 12 | private static FuseFileSystem _fs; 13 | 14 | private static void HandleFatalException(Exception ex) 15 | { 16 | Debug.WriteLine(ex?.Message); 17 | Debug.WriteLine(ex?.StackTrace); 18 | if (_terminateReceived) 19 | { 20 | Environment.Exit(1); 21 | } 22 | 23 | _terminateReceived = true; 24 | try 25 | { 26 | if (!IsWindows()) 27 | { 28 | _fs?.UnMount(); 29 | } 30 | 31 | Environment.Exit(1); 32 | } 33 | catch (Exception e) 34 | { 35 | Debug.WriteLine(e.Message); 36 | Debug.WriteLine(e.StackTrace); 37 | 38 | Environment.Exit(1); 39 | } 40 | } 41 | 42 | private static bool IsWindows() 43 | { 44 | var env = Environment.OSVersion.Platform; 45 | return env == PlatformID.Win32NT || 46 | env == PlatformID.Win32S || 47 | env == PlatformID.Win32Windows || 48 | env == PlatformID.WinCE || 49 | env == PlatformID.Xbox; 50 | } 51 | 52 | private static void WindowsMain() 53 | { 54 | Console.WriteLine("In interactive query mode. Write a file path and press return to query."); 55 | Console.WriteLine("An empty line terminates input."); 56 | Console.WriteLine("-----------"); 57 | while (true) 58 | { 59 | Console.Write("> "); 60 | var line = Console.ReadLine(); 61 | if (string.IsNullOrWhiteSpace(line)) 62 | { 63 | break; 64 | } 65 | 66 | Console.WriteLine(_fs.TestInput(line)); 67 | } 68 | } 69 | 70 | public static async Task Main() 71 | { 72 | AppDomain.CurrentDomain.UnhandledException += 73 | (sender, e) => HandleFatalException(e.ExceptionObject as Exception); 74 | 75 | var config = Configuration.LoadConfig(); 76 | if (string.IsNullOrWhiteSpace(config.Cid) || string.IsNullOrWhiteSpace(config.Token)) 77 | { 78 | var (cid, token) = await PlexOAuth.GetLoginDetails(); 79 | config.Cid = cid; 80 | config.Token = token; 81 | if (config.SaveLoginDetails) 82 | { 83 | Configuration.SaveConfig(config); 84 | } 85 | } 86 | 87 | try 88 | { 89 | _fs = new FuseFileSystem(config); 90 | if (IsWindows()) 91 | { 92 | WindowsMain(); 93 | } 94 | else 95 | { 96 | _fs.Mount(); 97 | } 98 | } 99 | catch (Exception e) 100 | { 101 | HandleFatalException(e); 102 | } 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mounts all plex servers you have access to as local filesystems so that you can download/copy files. 2 | 3 | See `config.example.json` for configuration options. Each of these options can either be set in `config.json` or passed as long parameters through stdin, e.g. `--mountPath /some/path`. If no plex token is set one will be retrieved on next run. -------------------------------------------------------------------------------- /Utils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | using System.Text.RegularExpressions; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Pfs.Plex.Model; 11 | 12 | namespace Pfs 13 | { 14 | public static class Utils 15 | { 16 | private static readonly Regex _windowsPathRegex = 17 | new Regex(@"^[a-zA-Z]:(\\|\/|$)", RegexOptions.Compiled | RegexOptions.Singleline); 18 | 19 | public static void CleanAndDedupe(IEnumerable items) 20 | { 21 | var fileNameCache = new HashSet(); 22 | var invalidChars = Path.GetInvalidFileNameChars(); 23 | foreach (var item in items) 24 | { 25 | var sanitised = new string(item.Name.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray()); 26 | var ext = Path.GetExtension(sanitised); 27 | var name = Path.GetFileNameWithoutExtension(sanitised); 28 | var addition = ""; 29 | var i = 0; 30 | while (fileNameCache.Contains(name + addition + ext)) 31 | { 32 | addition = i++ == 0 ? $" - ({item.Id})" : $" - {i}"; 33 | } 34 | 35 | var filename = name + addition + ext; 36 | fileNameCache.Add(filename); 37 | item.Name = filename; 38 | } 39 | } 40 | 41 | public static async Task FirstSuccessfulTask(IEnumerable> tasks) 42 | { 43 | var taskList = tasks.ToList(); 44 | var tcs = new TaskCompletionSource(); 45 | var remainingTasks = taskList.Count; 46 | foreach (var task in taskList) 47 | { 48 | task.ContinueWith(t => 49 | { 50 | if (task.Status == TaskStatus.RanToCompletion) 51 | { 52 | tcs.TrySetResult(t.Result); 53 | } 54 | else if (Interlocked.Decrement(ref remainingTasks) == 0) 55 | { 56 | tcs.TrySetResult(default(T)); 57 | } 58 | }); 59 | } 60 | 61 | return await tcs.Task; 62 | } 63 | 64 | public static long ToUnixTimestamp(this DateTime dateTime) 65 | { 66 | return (long)(TimeZoneInfo.ConvertTimeToUtc(dateTime) - 67 | new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc)).TotalSeconds; 68 | } 69 | 70 | public static DateTime ToDateTime(this long val) 71 | { 72 | return DateTimeOffset.FromUnixTimeSeconds(val).DateTime; 73 | } 74 | 75 | public static DateTime ToDateTime(this string val) 76 | { 77 | return ToDateTime(long.Parse(val)); 78 | } 79 | 80 | public class PlexDateTimeConverter : JsonConverter 81 | { 82 | public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 83 | { 84 | return reader.GetInt64().ToDateTime(); 85 | } 86 | 87 | public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) 88 | { 89 | throw new NotImplementedException(); 90 | } 91 | } 92 | 93 | public static (string directory, string filename) GetPathInfo(string path) 94 | { 95 | if (path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar)) 96 | { 97 | path = path.Substring(0, path.Length - 1); 98 | } 99 | 100 | var i = Math.Max(path.LastIndexOf(Path.DirectorySeparatorChar), 101 | path.LastIndexOf(Path.AltDirectorySeparatorChar)); 102 | var folder = i <= 0 ? "/" : path.Substring(0, i); 103 | var file = i + 2 > path.Length ? "" : path.Substring(i + 1); 104 | 105 | return (folder, file); 106 | } 107 | 108 | public static string NormalisePath(string path) 109 | { 110 | var inputPath = _windowsPathRegex.Replace(path, "\\") 111 | .Replace('?', '\n') 112 | .Replace('#', '\r'); 113 | return new Uri($"file://{inputPath}") 114 | .LocalPath 115 | .Replace("\\", "") 116 | .Replace('\r', '#') 117 | .Replace('\n', '?') 118 | .Trim(); 119 | } 120 | } 121 | } -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "cid": "PlexFSv1", 3 | "token": "mysecretplextoken", 4 | "saveLoginDetails": true, 5 | "mountPath": "/path/to/mount/location", 6 | "uid": 1000, 7 | "gid": 1000, 8 | "cacheAge": 3600000, 9 | "forceMount": false, 10 | "macDisplayMount": false, 11 | "fuseOptions": ["large_read"] 12 | } -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "8.0.0", 4 | "rollForward": "latestMajor", 5 | "allowPrerelease": false 6 | } 7 | } -------------------------------------------------------------------------------- /pfs.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | 12 7 | Pfs 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | --------------------------------------------------------------------------------