Skip to content

Commit

Permalink
Add File Station support (#371)
Browse files Browse the repository at this point in the history
* list folders and files, uoload file

* increase timeout to max 12h

* no need for special post_upload function

* params has always keys in _execute_request()

* allow to use raw response content (StreamReader)

* fix

* add download_file, improve upload_file

* add delete_file

* small things

* add tests

* add docs

* extend docs

* add create_parents to upload
  • Loading branch information
mib1185 authored Jan 6, 2025
1 parent 8787a3a commit 7f348d6
Show file tree
Hide file tree
Showing 11 changed files with 771 additions and 24 deletions.
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,101 @@ if __name__ == "__main__":
asyncio.run(main())
```

## File Station usage

### List folders and files in specific folder

```python
import asyncio
import aiohttp
from synology_dsm import SynologyDSM

async def main():
print("Creating Valid API")
async with aiohttp.ClientSession(
connector=aiohttp.TCPConnector(verify_ssl=False)
) as session:
await do(session)

async def do(session: aiohttp.ClientSession):
api = SynologyDSM(session, "<IP/DNS>", "<port>", "<username>", "<password>")
await api.login()

shared_folders = await api.file.get_shared_folders()
for folder in shared_folders:
print(f"############### {folder.name} ###############")
print(f"path: {folder.path}")
print(f"freespace: {folder.additional.volume_status.freespace}")
print(f"totalspace: {folder.additional.volume_status.totalspace}")
print(f"readonly: {folder.additional.volume_status.readonly}")

files = await api.file.get_files(path="/home")
for file in files:
print(f"path: {file.path}")
print(f"size: {file.additional.size}")
print(f"is dir: {file.is_dir}")
print(f"owner user: {file.additional.owner.user}")

if __name__ == "__main__":
asyncio.run(main())
```

### Upload, Download and Delete files

```python
import asyncio
import aiohttp
from synology_dsm import SynologyDSM

async def main():
print("Creating Valid API")
async with aiohttp.ClientSession(
connector=aiohttp.TCPConnector(verify_ssl=False)
) as session:
await do(session)

async def do(session: aiohttp.ClientSession):
api = SynologyDSM(session, "<IP/DNS>", "<port>", "<username>", "<password>")
await api.login()

# upload file direct from local
await api.file.upload_file(path="/home", filename="myfile.name", source="/workspace/myfile.name")

# upload file direct from local, create parent folder(s) if none exist.
await api.file.upload_file(path="/home/new_folder", filename="myfile.name", source="/workspace/myfile.name", create_parents=True)

# upload file from a stream reader
await api.file.upload_file(path="/home", filename="myfile.name", source=open("/workspace/myfile.name", "rb"))

# upload file from an AsyncIterator[bytes]
loop = asyncio.get_running_loop()
async def send_backup() -> AsyncIterator[bytes]:
f = await loop.run_in_executor(None, open, "/workspace/myfile.name", "rb")
try:
while chunk := await loop.run_in_executor(None, f.read, 2**20):
yield chunk
finally:
await loop.run_in_executor(None, f.close)

async def open_backup() -> AsyncIterator[bytes]:
return send_backup()
await api.file.upload_file(path="/home", filename="myfile.name", source=await open_backup())

# download file direct to local
await api.file.download_file(path="/home", filename="myfile.name", target_file="/tmp/download.file")

# download file via stream reader
stream_reader = await api.file.download_file(path="/home", filename="myfile.name")
with open("/tmp/download.file", "wb") as fh:
async for data in stream_reader.iter_chunked(8192):
fh.write(data)

await api.file.delete_file(path="/home", filename="myfile.name")

if __name__ == "__main__":
asyncio.run(main())
```

## External USB storage usage

```python
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ disable = [
"too-many-arguments",
"too-many-branches",
"too-many-instance-attributes",
"too-many-locals",
"too-many-public-methods",
"too-many-return-statements",
"too-many-positional-arguments",
"too-many-statements",
"too-many-return-statements",
]
170 changes: 170 additions & 0 deletions src/synology_dsm/api/file_station/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""Synology FileStation API wrapper."""

from __future__ import annotations

from collections.abc import AsyncIterator
from io import BufferedReader

from aiohttp import StreamReader

from synology_dsm.api import SynoBaseApi

from .models import (
SynoFileAdditionalOwner,
SynoFileFile,
SynoFileFileAdditional,
SynoFileFileAdditionalPermission,
SynoFileFileAdditionalTime,
SynoFileSharedFolder,
SynoFileSharedFolderAdditional,
SynoFileSharedFolderAdditionalPermission,
SynoFileSharedFolderAdditionalVolumeStatus,
)


class SynoFileStation(SynoBaseApi):
"""An implementation of a Synology FileStation."""

API_KEY = "SYNO.FileStation.*"
LIST_API_KEY = "SYNO.FileStation.List"
DOWNLOAD_API_KEY = "SYNO.FileStation.Download"
UPLOAD_API_KEY = "SYNO.FileStation.Upload"
DELETE_API_KEY = "SYNO.FileStation.Delete"

async def get_shared_folders(
self, offset: int = 0, limit: int = 100, only_writable: bool = False
) -> list[SynoFileSharedFolder] | None:
"""Get a list of all shared folders."""
raw_data = await self._dsm.get(
self.LIST_API_KEY,
"list_share",
{
"offset": offset,
"limit": limit,
"onlywritable": only_writable,
"additional": (
'["real_path","owner","time","perm",'
'"mount_point_type","sync_share","volume_status"]'
),
},
)
if not isinstance(raw_data, dict) or (data := raw_data.get("data")) is None:
return None

shared_folders: list[SynoFileSharedFolder] = []
for folder in data["shares"]:
additional = folder["additional"]
shared_folders.append(
SynoFileSharedFolder(
SynoFileSharedFolderAdditional(
additional["mount_point_type"],
SynoFileAdditionalOwner(**additional["owner"]),
SynoFileSharedFolderAdditionalPermission(**additional["perm"]),
SynoFileSharedFolderAdditionalVolumeStatus(
**additional["volume_status"],
),
),
folder["isdir"],
folder["name"],
folder["path"],
)
)

return shared_folders

async def get_files(
self, path: str, offset: int = 0, limit: int = 100
) -> list[SynoFileFile] | None:
"""Get a list of all files in a folder."""
raw_data = await self._dsm.get(
self.LIST_API_KEY,
"list",
{
"offset": offset,
"limit": limit,
"folder_path": path,
"additional": (
'["real_path","owner","time","perm",'
'"mount_point_type","type","size"]'
),
},
)
if not isinstance(raw_data, dict) or (data := raw_data.get("data")) is None:
return None

files: list[SynoFileFile] = []
for file in data["files"]:
additional = file["additional"]
files.append(
SynoFileFile(
SynoFileFileAdditional(
additional["mount_point_type"],
SynoFileAdditionalOwner(**additional["owner"]),
SynoFileFileAdditionalPermission(**additional["perm"]),
additional["real_path"],
additional["size"],
SynoFileFileAdditionalTime(**additional["time"]),
additional["type"],
),
file["isdir"],
file["name"],
file["path"],
)
)

return files

async def upload_file(
self,
path: str,
filename: str,
source: bytes | BufferedReader | AsyncIterator[bytes] | str,
create_parents: bool = False,
) -> bool | None:
"""Upload a file to a folder from eather a local source_file or content."""
if isinstance(source, str):
source = open(source, "rb")

raw_data = await self._dsm.post(
self.UPLOAD_API_KEY,
"upload",
path=path,
filename=filename,
content=source,
create_parents=create_parents,
)
if not isinstance(raw_data, dict):
return None
return raw_data.get("success")

async def download_file(
self, path: str, filename: str, target_file: str | None = None
) -> StreamReader | bool | None:
"""Download a file to local target_file or returns an async StreamReader."""
response_content = await self._dsm.get(
self.DOWNLOAD_API_KEY,
"download",
{"path": f"{path}/{filename}", "mode": "download"},
raw_response_content=True,
)
if not isinstance(response_content, StreamReader):
return None

if target_file:
with open(target_file, "wb") as fh:
async for data in response_content.iter_chunked(8192):
fh.write(data)
return True

return response_content

async def delete_file(self, path: str, filename: str) -> bool | None:
"""Delete a file."""
raw_data = await self._dsm.get(
self.DELETE_API_KEY,
"delete",
{"path": f"{path}/{filename}", "recursive": False},
)
if not isinstance(raw_data, dict):
return None
return raw_data.get("success")
113 changes: 113 additions & 0 deletions src/synology_dsm/api/file_station/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Data models for Synology FileStation Module."""

from __future__ import annotations

from dataclasses import dataclass

# -------------------------------------
# generic additional data
# -------------------------------------


@dataclass
class SynoFileAdditionalOwner:
"""Representation of an Synology FileStation additionl owner data."""

gid: int
group: str
uid: int
user: str


# -------------------------------------
# shared folder
# -------------------------------------


@dataclass
class SynoFileSharedFolderAdditionalPermission:
"""Representation of an Synology FileStation additionl permission data."""

acl: dict
acl_enable: bool
adv_right: dict
is_acl_mode: bool
is_share_readonly: bool
posix: int
share_right: str


@dataclass
class SynoFileSharedFolderAdditionalVolumeStatus:
"""Representation of an Synology FileStation additionl permission data."""

freespace: int
totalspace: int
readonly: bool


@dataclass
class SynoFileSharedFolderAdditional:
"""Representation of an Synology FileStation Shared Folder additionl data."""

mount_point_type: str
owner: SynoFileAdditionalOwner
perm: SynoFileSharedFolderAdditionalPermission
volume_status: SynoFileSharedFolderAdditionalVolumeStatus


@dataclass
class SynoFileSharedFolder:
"""Representation of an Synology FileStation Shared Folder."""

additional: SynoFileSharedFolderAdditional
is_dir: bool
name: str
path: str


# -------------------------------------
# file
# -------------------------------------


@dataclass
class SynoFileFileAdditionalPermission:
"""Representation of an Synology FileStation additionl permission data."""

acl: dict
is_acl_mode: bool
posix: int


@dataclass
class SynoFileFileAdditionalTime:
"""Representation of an Synology FileStation additionl permission data."""

atime: int
ctime: int
crtime: int
mtime: int


@dataclass
class SynoFileFileAdditional:
"""Representation of an Synology FileStation File additionl data."""

mount_point_type: str
owner: SynoFileAdditionalOwner
perm: SynoFileFileAdditionalPermission
real_path: str
size: int
time: SynoFileFileAdditionalTime
type: str


@dataclass
class SynoFileFile:
"""Representation of an Synology FileStation File."""

additional: SynoFileFileAdditional
is_dir: bool
name: str
path: str
Loading

0 comments on commit 7f348d6

Please sign in to comment.